turbo_live 0.1.0 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +207 -16
- data/app/channels/components_channel.rb +20 -0
- data/app/controllers/turbo_live/components_controller.rb +10 -0
- data/config/routes.rb +3 -0
- data/examples/countdown_component.rb +23 -0
- data/examples/counter_component.rb +26 -0
- data/examples/showcase_component.rb +41 -0
- data/examples/tic_tac_toe_component.rb +93 -0
- data/lib/turbo_live/component.rb +85 -0
- data/lib/turbo_live/engine.rb +13 -0
- data/lib/turbo_live/renderer.rb +55 -0
- data/lib/turbo_live/version.rb +1 -1
- data/lib/turbo_live.rb +17 -1
- data/package-lock.json +33 -0
- data/package.json +29 -0
- data/src/.npmignore +1 -0
- data/src/js/channels/register_channels.js +7 -0
- data/src/js/channels/turbo_live_channel.js +31 -0
- data/src/js/controllers/register_controllers.js +7 -0
- data/src/js/controllers/turbo_live_controller.js +116 -0
- data/src/js/core.js +5 -0
- metadata +52 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b117e175ea920117e11f0cd6c3bab5cafc0ca7c1860fb6b890c632a37efe1509
|
4
|
+
data.tar.gz: ca56b1155834dbeb85a29112a2f85b0901dbf14d61909587f698f45cfc25bb34
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fce48eb8c3ae06f14fb6bb365004cf399c4f85d4f147776a7b05d6d7ec4b2833182fe6e4fd5d99781ef555daaad2d0bcd4b7b757378c645a7486138c2d356809
|
7
|
+
data.tar.gz: f0b1875a9143f5ca1ca218c05b1dbd3439ff7ebb13b8aabe9d72fcd58746fe41e8cde508f3a41f138ae3acfb25c66247594c1856397eaa89d2b9f6aaf170bea4
|
data/README.md
CHANGED
@@ -1,39 +1,230 @@
|
|
1
1
|
# TurboLive
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
3
|
+
TurboLive is a Ruby gem that enables the creation of async, progressively enhanced, live components for Ruby applications. It works seamlessly over both WebSockets and HTTPS, providing real-time interactivity with graceful degradation.
|
4
|
+
|
5
|
+
## Table of Contents
|
6
|
+
|
7
|
+
- [Installation](#installation)
|
8
|
+
- [Setup](#setup)
|
9
|
+
- [Usage](#usage)
|
10
|
+
- [Creating a Component](#creating-a-component)
|
11
|
+
- [Model State](#model-state)
|
12
|
+
- [View](#view)
|
13
|
+
- [Update](#update)
|
14
|
+
- [Events](#events)
|
15
|
+
- [Manual Events](#manual-events)
|
16
|
+
- [Timed Events](#timed-events)
|
17
|
+
- [Examples](#examples)
|
18
|
+
- [Performance Considerations](#performance-considerations)
|
19
|
+
- [Testing](#testing)
|
20
|
+
- [Troubleshooting](#troubleshooting)
|
21
|
+
- [Contributing](#contributing)
|
22
|
+
- [Changelog](#changelog)
|
23
|
+
- [License](#license)
|
6
24
|
|
7
25
|
## Installation
|
8
26
|
|
9
|
-
|
27
|
+
Add it to your project with:
|
28
|
+
|
29
|
+
```console
|
30
|
+
bundle add 'turbo_live'
|
31
|
+
```
|
10
32
|
|
11
|
-
|
33
|
+
Or install it yourself using:
|
12
34
|
|
13
|
-
```
|
14
|
-
|
35
|
+
```console
|
36
|
+
gem install turbo_live
|
15
37
|
```
|
16
38
|
|
17
|
-
|
39
|
+
### JavaScript
|
40
|
+
|
41
|
+
TurboLive ships a JavaScript component that comes as an npm package. You can pin it with importmaps or install it as an npm package depending on your asset pipeline:
|
42
|
+
|
43
|
+
For importmaps:
|
18
44
|
|
19
|
-
```
|
20
|
-
|
45
|
+
```console
|
46
|
+
bin/importmap pin @radioactive-labs/turbo-live
|
47
|
+
```
|
48
|
+
|
49
|
+
For npm:
|
50
|
+
|
51
|
+
```console
|
52
|
+
npm install @radioactive-labs/turbo-live
|
53
|
+
```
|
54
|
+
|
55
|
+
## Setup
|
56
|
+
|
57
|
+
### Stimulus Controller
|
58
|
+
|
59
|
+
TurboLive uses a Stimulus controller to manage interactions. In your `app/javascript/controllers/index.js`:
|
60
|
+
|
61
|
+
```diff
|
62
|
+
import { application } from "controllers/application"
|
63
|
+
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
64
|
+
+import * as turboLive from "@radioactive-labs/turbo-live"
|
65
|
+
|
66
|
+
eagerLoadControllersFrom("controllers", application)
|
67
|
+
+turboLive.registerControllers(application)
|
68
|
+
```
|
69
|
+
|
70
|
+
### ActionCable (Optional)
|
71
|
+
|
72
|
+
TurboLive supports WebSockets using ActionCable with automatic failover to HTTPS. If you have ActionCable set up and would like to benefit from better performance, you can set up the integration.
|
73
|
+
|
74
|
+
In `app/javascript/channels/index.js`:
|
75
|
+
|
76
|
+
```diff
|
77
|
+
+import consumer from "./consumer"
|
78
|
+
+import * as turboLive from "@radioactive-labs/turbo-live"
|
79
|
+
+
|
80
|
+
+turboLive.registerChannels(consumer)
|
81
|
+
```
|
82
|
+
|
83
|
+
Then in your `app/javascript/application.js`:
|
84
|
+
|
85
|
+
```diff
|
86
|
+
import "@hotwired/turbo-rails"
|
87
|
+
import "controllers"
|
88
|
+
+import "channels"
|
21
89
|
```
|
22
90
|
|
23
91
|
## Usage
|
24
92
|
|
25
|
-
|
93
|
+
A TurboLive component is a self-contained, interactive unit of a web application that can update in real-time without full page reloads. Components follow [The Elm Architecture](https://guide.elm-lang.org/architecture/) pattern.
|
94
|
+
|
95
|
+
### Creating a Component
|
96
|
+
|
97
|
+
To create a TurboLive component, inherit from `TurboLive::Component`:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
class MyComponent < TurboLive::Component
|
101
|
+
# Component logic goes here
|
102
|
+
end
|
103
|
+
```
|
104
|
+
|
105
|
+
### Model State
|
106
|
+
|
107
|
+
Define state variables using the `state` method:
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
class MyComponent < TurboLive::Component
|
111
|
+
state :count, Integer do |value|
|
112
|
+
value || 0
|
113
|
+
end
|
114
|
+
end
|
115
|
+
```
|
116
|
+
|
117
|
+
> Note: State variables can only be primitive objects and basic collections.
|
118
|
+
|
119
|
+
### View
|
120
|
+
|
121
|
+
Define the component's HTML structure in the `view` method:
|
26
122
|
|
27
|
-
|
123
|
+
```ruby
|
124
|
+
def view
|
125
|
+
div do
|
126
|
+
button(**on(click: :increment)) { "+" }
|
127
|
+
span { count }
|
128
|
+
button(**on(click: :decrement)) { "-" }
|
129
|
+
end
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
Components are [phlex](https://www.phlex.fun/) views, allowing you to write HTML in Ruby.
|
134
|
+
|
135
|
+
### Update
|
28
136
|
|
29
|
-
|
137
|
+
Handle events in the `update` method:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
def update(input)
|
141
|
+
case input
|
142
|
+
in [:increment]
|
143
|
+
self.count += 1
|
144
|
+
in [:decrement]
|
145
|
+
self.count -= 1
|
146
|
+
end
|
147
|
+
end
|
148
|
+
```
|
30
149
|
|
31
|
-
|
150
|
+
## Events
|
151
|
+
|
152
|
+
Events are transmitted to the server using the currently active transport (HTTP or WebSockets).
|
153
|
+
|
154
|
+
### Manual Events
|
155
|
+
|
156
|
+
Use the `on` method to set up manually triggered events:
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
button(**on(click: :decrement)) { "-" }
|
160
|
+
```
|
161
|
+
|
162
|
+
You can also emit compound events that carry extra data:
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
button(**on(click: [:change_value, 1])) { "+" }
|
166
|
+
```
|
167
|
+
|
168
|
+
> Note: Currently, only `:click` and `:change` events are supported.
|
169
|
+
|
170
|
+
### Timed Events
|
171
|
+
|
172
|
+
Use the `every` method to set up recurring events:
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
def view
|
176
|
+
div do
|
177
|
+
h1 { countdown }
|
178
|
+
every(1000, :tick) if countdown > 0
|
179
|
+
end
|
180
|
+
end
|
181
|
+
```
|
182
|
+
|
183
|
+
## Examples
|
184
|
+
|
185
|
+
See the [/examples](/examples) folder in for detailed component examples including Counter, Countdown, Showcase and Tic-Tac-Toe components.
|
186
|
+
|
187
|
+
## Performance Considerations
|
188
|
+
|
189
|
+
- Use fine-grained components to minimize the amount of data transferred and rendered.
|
190
|
+
- Implement debouncing for frequently triggered events.
|
191
|
+
- Consider using background jobs for heavy computations to keep the UI responsive.
|
192
|
+
|
193
|
+
## Testing
|
194
|
+
|
195
|
+
TurboLive components can be tested using standard Rails testing tools. Here's a basic example:
|
196
|
+
|
197
|
+
```ruby
|
198
|
+
require "test_helper"
|
199
|
+
|
200
|
+
class CounterComponentTest < ActiveSupport::TestCase
|
201
|
+
test "increments count" do
|
202
|
+
component = CounterComponent.new
|
203
|
+
assert_equal 0, component.count
|
204
|
+
component.update([:increment])
|
205
|
+
assert_equal 1, component.count
|
206
|
+
end
|
207
|
+
end
|
208
|
+
```
|
209
|
+
|
210
|
+
## Troubleshooting
|
211
|
+
|
212
|
+
Common issues and their solutions:
|
213
|
+
|
214
|
+
1. **Component not updating**: Ensure that your `update` method is correctly handling the event and modifying the state.
|
215
|
+
2. **WebSocket connection failing**: Check your ActionCable configuration and ensure that your server supports WebSocket connections.
|
216
|
+
3. **JavaScript errors**: Make sure you've correctly set up the TurboLive JavaScript integration in your application.
|
217
|
+
|
218
|
+
For more issues, please check our [FAQ](https://github.com/radioactive-labs/turbo_live/wiki/FAQ) or open an issue on GitHub.
|
32
219
|
|
33
220
|
## Contributing
|
34
221
|
|
35
|
-
|
222
|
+
We welcome contributions to TurboLive! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more information on how to get started.
|
223
|
+
|
224
|
+
## Changelog
|
225
|
+
|
226
|
+
See the [CHANGELOG.md](CHANGELOG.md) file for details on each release.
|
36
227
|
|
37
228
|
## License
|
38
229
|
|
39
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
230
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboLive
|
4
|
+
class ComponentsChannel < ActionCable::Channel::Base
|
5
|
+
def subscribed
|
6
|
+
stream_from stream_name
|
7
|
+
end
|
8
|
+
|
9
|
+
def receive(params)
|
10
|
+
stream = Renderer.render params
|
11
|
+
ActionCable.server.broadcast(stream_name, stream)
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
def stream_name
|
17
|
+
@stream_name ||= "turbo_live-#{SecureRandom.hex}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
class CountdownComponent < TurboLive::Component
|
2
|
+
state :countdown, Integer
|
3
|
+
|
4
|
+
def view
|
5
|
+
div do
|
6
|
+
if countdown.nil?
|
7
|
+
button(**on(click: :start)) { "Start!" }
|
8
|
+
else
|
9
|
+
h1 { countdown }
|
10
|
+
every(1000, :countdown) if countdown >= 1
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def update(input)
|
16
|
+
case input
|
17
|
+
in [:countdown]
|
18
|
+
self.countdown -= 1
|
19
|
+
in [:start]
|
20
|
+
self.countdown = 1000
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
class CounterComponent < TurboLive::Component
|
2
|
+
state :count, Integer do |value|
|
3
|
+
value || 0
|
4
|
+
end
|
5
|
+
|
6
|
+
def view
|
7
|
+
div do
|
8
|
+
button(**on(click: :increment)) { "+" }
|
9
|
+
span { " Clicked: #{count} " }
|
10
|
+
button(**on(click: :decrement)) { "-" }
|
11
|
+
plain " "
|
12
|
+
button(**on(click: :reset)) { "Reset" }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def update(input)
|
17
|
+
case input
|
18
|
+
in [:decrement]
|
19
|
+
self.count -= 1
|
20
|
+
in [:increment]
|
21
|
+
self.count += 1
|
22
|
+
in [:reset]
|
23
|
+
self.count = 0
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class ShowcaseComponent < TurboLive::Component
|
2
|
+
state :component, Symbol
|
3
|
+
|
4
|
+
def view
|
5
|
+
div class: "container" do
|
6
|
+
div class: "left-column" do
|
7
|
+
h2 { "Components" }
|
8
|
+
ul do
|
9
|
+
li { button(**on(click: [:change_component, :counter])) { "Counter" } }
|
10
|
+
li { button(**on(click: [:change_component, :countdown])) { "Countdown" } }
|
11
|
+
li { button(**on(click: [:change_component, :tic_tac_toe])) { "TicTacToe" } }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
div class: "right-column" do
|
15
|
+
div class: "card" do
|
16
|
+
render selected_component.new
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def update(input)
|
23
|
+
case input
|
24
|
+
in [[:change_component, component]]
|
25
|
+
self.component = component
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def selected_component
|
32
|
+
case component
|
33
|
+
when :countdown
|
34
|
+
CountdownComponent
|
35
|
+
when :tic_tac_toe
|
36
|
+
TicTacToeComponent
|
37
|
+
else
|
38
|
+
CounterComponent
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
class TicTacToeComponent < TurboLive::Component
|
2
|
+
state :board, Array do |value|
|
3
|
+
value || Array.new(9, nil)
|
4
|
+
end
|
5
|
+
|
6
|
+
state :current_player, String do |value|
|
7
|
+
value || "X"
|
8
|
+
end
|
9
|
+
|
10
|
+
state :winner, String do |value|
|
11
|
+
value || nil
|
12
|
+
end
|
13
|
+
|
14
|
+
def view
|
15
|
+
div(class: "tic-tac-toe-container") do
|
16
|
+
render_board
|
17
|
+
render_status
|
18
|
+
render_reset_button
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def render_board
|
23
|
+
div(class: "board") do
|
24
|
+
board.each_with_index do |cell, index|
|
25
|
+
button(
|
26
|
+
class: "cell",
|
27
|
+
**on(click: [:make_move, index]),
|
28
|
+
disabled: cell || winner,
|
29
|
+
"data-value": cell
|
30
|
+
) { cell || " ".html_safe }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def render_status
|
36
|
+
div(class: "status") do
|
37
|
+
if winner
|
38
|
+
"Winner: #{winner}"
|
39
|
+
elsif board.compact.length == 9
|
40
|
+
"It's a draw!"
|
41
|
+
else
|
42
|
+
"Current player: #{current_player}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def render_reset_button
|
48
|
+
button(**on(click: :reset_game)) { "Reset Game" }
|
49
|
+
end
|
50
|
+
|
51
|
+
def update(input)
|
52
|
+
case input
|
53
|
+
in [[:make_move, index]]
|
54
|
+
make_move(index)
|
55
|
+
in [:reset_game]
|
56
|
+
reset_game
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def make_move(index)
|
63
|
+
return if board[index] || winner
|
64
|
+
|
65
|
+
new_board = board.dup
|
66
|
+
new_board[index] = current_player
|
67
|
+
self.board = new_board
|
68
|
+
self.winner = check_winner
|
69
|
+
self.current_player = (current_player == "X") ? "O" : "X" unless winner
|
70
|
+
end
|
71
|
+
|
72
|
+
def check_winner
|
73
|
+
winning_combinations = [
|
74
|
+
[0, 1, 2], [3, 4, 5], [6, 7, 8], # Rows
|
75
|
+
[0, 3, 6], [1, 4, 7], [2, 5, 8], # Columns
|
76
|
+
[0, 4, 8], [2, 4, 6] # Diagonals
|
77
|
+
]
|
78
|
+
|
79
|
+
winning_combinations.each do |combo|
|
80
|
+
if board[combo[0]] && board[combo[0]] == board[combo[1]] && board[combo[0]] == board[combo[2]]
|
81
|
+
return board[combo[0]]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
nil
|
86
|
+
end
|
87
|
+
|
88
|
+
def reset_game
|
89
|
+
self.board = Array.new(9, nil)
|
90
|
+
self.current_player = "X"
|
91
|
+
self.winner = nil
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "phlex"
|
4
|
+
|
5
|
+
module TurboLive
|
6
|
+
class Component < Phlex::HTML
|
7
|
+
extend Literal::Properties
|
8
|
+
|
9
|
+
SUPPORTED_EVENTS = %i[click change].freeze
|
10
|
+
|
11
|
+
def self.state(name, type, **options, &block)
|
12
|
+
options = {reader: :public, writer: :protected}.merge(**options).compact
|
13
|
+
prop(name, _Nilable(type), **options, &block)
|
14
|
+
end
|
15
|
+
|
16
|
+
state :live_id, String, writer: nil do |value|
|
17
|
+
value || SecureRandom.hex
|
18
|
+
end
|
19
|
+
|
20
|
+
def view_template
|
21
|
+
div(
|
22
|
+
id: verifiable_live_id,
|
23
|
+
style: "display: contents;",
|
24
|
+
data_controller: "turbo-live",
|
25
|
+
data_turbo_live_component_value: to_verifiable(serialize)
|
26
|
+
) do
|
27
|
+
view
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def update(input)
|
32
|
+
end
|
33
|
+
|
34
|
+
def verifiable_live_id
|
35
|
+
to_verifiable(live_id)
|
36
|
+
end
|
37
|
+
|
38
|
+
protected
|
39
|
+
|
40
|
+
def on(**mappings)
|
41
|
+
actions = []
|
42
|
+
params = []
|
43
|
+
mappings.each do |event, param|
|
44
|
+
raise NotImplementedError, "TurboLive does not support '#{event}' events" unless SUPPORTED_EVENTS.include?(event)
|
45
|
+
|
46
|
+
actions << "#{event}->turbo-live#on#{event.capitalize}"
|
47
|
+
params << [:"data_turbo_live_#{event}_param", to_verifiable(param)]
|
48
|
+
end
|
49
|
+
|
50
|
+
data_action = actions.join(" ")
|
51
|
+
params.to_h.merge(data_action: data_action)
|
52
|
+
end
|
53
|
+
|
54
|
+
def every(milliseconds, event)
|
55
|
+
data = {milliseconds => to_verifiable(event)}.to_json
|
56
|
+
add_data :every, data
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def add_data(type, value)
|
62
|
+
# Temporary hack to embed data.
|
63
|
+
# Switch to HTML templates
|
64
|
+
div(
|
65
|
+
class: "turbo-live-data",
|
66
|
+
data_turbo_live_id: verifiable_live_id,
|
67
|
+
data_turbo_live_data_type: type,
|
68
|
+
data_turbo_live_data_value: value,
|
69
|
+
style: "display: none;", display: :none
|
70
|
+
) {}
|
71
|
+
end
|
72
|
+
|
73
|
+
def serialize
|
74
|
+
state = self.class.literal_properties.map do |prop|
|
75
|
+
[prop.name, instance_variable_get(:"@#{prop.name}")]
|
76
|
+
end.to_h
|
77
|
+
|
78
|
+
{klass: self.class.to_s, state: state}
|
79
|
+
end
|
80
|
+
|
81
|
+
def to_verifiable(value)
|
82
|
+
TurboLive.verifier.generate value
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboLive
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace TurboLive
|
6
|
+
|
7
|
+
initializer "turbo_live.verifier_key" do
|
8
|
+
config.after_initialize do
|
9
|
+
TurboLive.verifier_key = Rails.application.key_generator.generate_key("turbo_live/verifier_key")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboLive
|
4
|
+
class Renderer
|
5
|
+
class << self
|
6
|
+
def render(data)
|
7
|
+
data = data.symbolize_keys
|
8
|
+
# build the payload
|
9
|
+
payload = extract_payload(data)
|
10
|
+
# create the component
|
11
|
+
component = build_component(data)
|
12
|
+
# run the update function
|
13
|
+
component.update payload
|
14
|
+
# render the replace stream
|
15
|
+
<<~STREAM
|
16
|
+
<turbo-stream action="replace" target="#{data[:id]}">
|
17
|
+
<template>
|
18
|
+
#{component.call}
|
19
|
+
</template>
|
20
|
+
</turbo-stream>
|
21
|
+
STREAM
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def extract_payload(data)
|
27
|
+
payload_event = from_verifiable(data[:payload][0])
|
28
|
+
if data[:payload].size == 2
|
29
|
+
[payload_event, data[:payload][1]]
|
30
|
+
else
|
31
|
+
[payload_event]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def build_component(data)
|
36
|
+
component_data = from_verifiable(data[:component])
|
37
|
+
component_klass = component_data[:klass].safe_constantize
|
38
|
+
# Ensure we have a correct class
|
39
|
+
unless component_klass.is_a?(Class) && component_klass < Component
|
40
|
+
raise ArgumentError, "[IMPORTANT!!!] Unexpected class: #{component_klass}"
|
41
|
+
end
|
42
|
+
|
43
|
+
component = component_klass.new(**component_data[:state])
|
44
|
+
# rudimentary checksum to ensure id matches
|
45
|
+
raise ArgumentError, "component ID mismatch" unless component.verifiable_live_id == data[:id]
|
46
|
+
|
47
|
+
component
|
48
|
+
end
|
49
|
+
|
50
|
+
def from_verifiable(verifiable)
|
51
|
+
TurboLive.verifier.verified verifiable
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/lib/turbo_live/version.rb
CHANGED
data/lib/turbo_live.rb
CHANGED
@@ -1,8 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "turbo_live/version"
|
4
|
+
require_relative "turbo_live/component"
|
5
|
+
require_relative "turbo_live/renderer"
|
6
|
+
|
7
|
+
require_relative "turbo_live/engine" if defined?(Rails)
|
8
|
+
require_relative "../app/channels/components_channel" if defined?(ActionCable)
|
4
9
|
|
5
10
|
module TurboLive
|
6
11
|
class Error < StandardError; end
|
7
|
-
|
12
|
+
|
13
|
+
class << self
|
14
|
+
attr_writer :verifier_key
|
15
|
+
|
16
|
+
def verifier
|
17
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(verifier_key, digest: "SHA256", serializer: YAML)
|
18
|
+
end
|
19
|
+
|
20
|
+
def verifier_key
|
21
|
+
@verifier_key or raise ArgumentError, "Turbo requires a verifier_key"
|
22
|
+
end
|
23
|
+
end
|
8
24
|
end
|
data/package-lock.json
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
{
|
2
|
+
"name": "@radioactive-labs/turbo-live",
|
3
|
+
"version": "0.1.2",
|
4
|
+
"lockfileVersion": 3,
|
5
|
+
"requires": true,
|
6
|
+
"packages": {
|
7
|
+
"": {
|
8
|
+
"name": "@radioactive-labs/turbo-live",
|
9
|
+
"version": "0.1.2",
|
10
|
+
"license": "MIT",
|
11
|
+
"dependencies": {
|
12
|
+
"@hotwired/stimulus": "^3.2.2",
|
13
|
+
"@hotwired/turbo": "^8.0.4"
|
14
|
+
},
|
15
|
+
"devDependencies": {}
|
16
|
+
},
|
17
|
+
"node_modules/@hotwired/stimulus": {
|
18
|
+
"version": "3.2.2",
|
19
|
+
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
20
|
+
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
|
21
|
+
"license": "MIT"
|
22
|
+
},
|
23
|
+
"node_modules/@hotwired/turbo": {
|
24
|
+
"version": "8.0.10",
|
25
|
+
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.10.tgz",
|
26
|
+
"integrity": "sha512-xen1YhNQirAHlA8vr/444XsTNITC1Il2l/Vx4w8hAWPpI5nQO78mVHNsmFuayETodzPwh25ob2TgfCEV/Loiog==",
|
27
|
+
"license": "MIT",
|
28
|
+
"engines": {
|
29
|
+
"node": ">= 14"
|
30
|
+
}
|
31
|
+
}
|
32
|
+
}
|
33
|
+
}
|
data/package.json
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
{
|
2
|
+
"name": "@radioactive-labs/turbo-live",
|
3
|
+
"version": "0.1.2",
|
4
|
+
"description": "Async, progressively enhanced, live components for Ruby applications that work over Websockets and HTTP.",
|
5
|
+
"type": "module",
|
6
|
+
"main": "src/js/core.js",
|
7
|
+
"files": [
|
8
|
+
"src/"
|
9
|
+
],
|
10
|
+
"author": "Stefan Froelich (@thedumbtechguy)",
|
11
|
+
"license": "MIT",
|
12
|
+
"bugs": {
|
13
|
+
"url": "https://github.com/radioactive-labs/turbo_live/issues"
|
14
|
+
},
|
15
|
+
"repository": {
|
16
|
+
"type": "git",
|
17
|
+
"url": "git+https://github.com/radioactive-labs/turbo_live.git"
|
18
|
+
},
|
19
|
+
"publishConfig": {
|
20
|
+
"access": "public"
|
21
|
+
},
|
22
|
+
"homepage": "https://github.com/radioactive-labs/turbo_live#readme",
|
23
|
+
"dependencies": {
|
24
|
+
"@hotwired/stimulus": "^3.2.2",
|
25
|
+
"@hotwired/turbo": "^8.0.4"
|
26
|
+
},
|
27
|
+
"devDependencies": {},
|
28
|
+
"scripts": {}
|
29
|
+
}
|
data/src/.npmignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
build/
|
@@ -0,0 +1,31 @@
|
|
1
|
+
export default function (consumer) {
|
2
|
+
consumer.subscriptions.create({ channel: "TurboLive::ComponentsChannel" }, {
|
3
|
+
// Called once when the subscription is created.
|
4
|
+
initialized() {
|
5
|
+
console.log("TurboLiveChannel initialized")
|
6
|
+
},
|
7
|
+
|
8
|
+
// Called when the subscription is ready for use on the server.
|
9
|
+
connected() {
|
10
|
+
console.log("TurboLiveChannel connected")
|
11
|
+
window.turboLive = this;
|
12
|
+
},
|
13
|
+
|
14
|
+
received(turbo_stream) {
|
15
|
+
console.log("TurboLiveChannel received", turbo_stream)
|
16
|
+
Turbo.renderStreamMessage(turbo_stream);
|
17
|
+
},
|
18
|
+
|
19
|
+
// Called when the WebSocket connection is closed.
|
20
|
+
disconnected() {
|
21
|
+
console.log("TurboLiveChannel disconnected")
|
22
|
+
window.turboLive = null;
|
23
|
+
},
|
24
|
+
|
25
|
+
// Called when the subscription is rejected by the server.
|
26
|
+
rejected() {
|
27
|
+
console.log("TurboLiveChannel rejected")
|
28
|
+
window.turboLive = null;
|
29
|
+
},
|
30
|
+
})
|
31
|
+
}
|
@@ -0,0 +1,116 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static values = {
|
5
|
+
id: String,
|
6
|
+
component: String,
|
7
|
+
}
|
8
|
+
|
9
|
+
get component() {
|
10
|
+
return this.componentValue
|
11
|
+
}
|
12
|
+
|
13
|
+
connect() {
|
14
|
+
console.log("TurboLiveController connected:", this.element.id, this.component)
|
15
|
+
|
16
|
+
this.intervals = []
|
17
|
+
|
18
|
+
this.#readEmbeddedData()
|
19
|
+
}
|
20
|
+
|
21
|
+
disconnect() {
|
22
|
+
console.log("TurboLiveController disconnected")
|
23
|
+
this.#clearIntervals()
|
24
|
+
}
|
25
|
+
|
26
|
+
dispatch(event, payload) {
|
27
|
+
console.log("TurboLiveController dispatch:", this.element.id, event, payload)
|
28
|
+
let data = { id: this.element.id, event: event, payload: payload, component: this.component }
|
29
|
+
if (window.turboLive) {
|
30
|
+
console.log("TurboLiveController dispatching via websockets")
|
31
|
+
window.turboLive.send(data)
|
32
|
+
}
|
33
|
+
else {
|
34
|
+
console.log("TurboLiveController dispatching via HTTP")
|
35
|
+
|
36
|
+
fetch('/turbo_live', {
|
37
|
+
method: 'POST',
|
38
|
+
headers: {
|
39
|
+
'Content-Type': 'application/json',
|
40
|
+
},
|
41
|
+
body: JSON.stringify(data)
|
42
|
+
})
|
43
|
+
.then(response => {
|
44
|
+
if (!response.ok) {
|
45
|
+
throw new Error(`Network response was not OK`);
|
46
|
+
}
|
47
|
+
return response.text();
|
48
|
+
})
|
49
|
+
.then(turbo_stream => {
|
50
|
+
console.log('TurboLiveController dispatch success:', turbo_stream);
|
51
|
+
Turbo.renderStreamMessage(turbo_stream);
|
52
|
+
})
|
53
|
+
.catch((error) => {
|
54
|
+
console.error('TurboLiveController dispatch error:', error);
|
55
|
+
});
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
onClick(event) {
|
60
|
+
// event.preventDefault();
|
61
|
+
console.log("TurboLiveController onClick")
|
62
|
+
this.#dispatchSimpleEvent("click", event)
|
63
|
+
}
|
64
|
+
|
65
|
+
onChange(event) {
|
66
|
+
// event.preventDefault();
|
67
|
+
console.log("TurboLiveController onChange")
|
68
|
+
this.#dispatchValueEvent("change", event)
|
69
|
+
}
|
70
|
+
|
71
|
+
#readEmbeddedData() {
|
72
|
+
this.element.querySelectorAll(".turbo-live-data").forEach((element) => {
|
73
|
+
if (this.element.id != element.dataset.turboLiveId) return;
|
74
|
+
|
75
|
+
let type = element.dataset.turboLiveDataType
|
76
|
+
let value = JSON.parse(element.dataset.turboLiveDataValue)
|
77
|
+
switch (type) {
|
78
|
+
case "every":
|
79
|
+
this.#setupInterval(value)
|
80
|
+
break;
|
81
|
+
}
|
82
|
+
})
|
83
|
+
}
|
84
|
+
|
85
|
+
#setupInterval(intervalConfig) {
|
86
|
+
try {
|
87
|
+
for (let interval in intervalConfig) {
|
88
|
+
this.intervals.push(
|
89
|
+
setInterval(() => {
|
90
|
+
this.dispatch("every", [intervalConfig[interval]])
|
91
|
+
}, interval)
|
92
|
+
)
|
93
|
+
}
|
94
|
+
}
|
95
|
+
catch (e) {
|
96
|
+
console.error(e)
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
100
|
+
#clearIntervals() {
|
101
|
+
this.intervals.forEach((interval) => {
|
102
|
+
clearInterval(interval)
|
103
|
+
})
|
104
|
+
}
|
105
|
+
|
106
|
+
#dispatchSimpleEvent(name, { params }) {
|
107
|
+
let live_event = params[name]
|
108
|
+
this.dispatch(name, [live_event])
|
109
|
+
}
|
110
|
+
|
111
|
+
#dispatchValueEvent(name, { params, target }) {
|
112
|
+
let value = target.value
|
113
|
+
let live_event = params[name]
|
114
|
+
this.dispatch(name, [live_event, value])
|
115
|
+
}
|
116
|
+
}
|
data/src/js/core.js
ADDED
metadata
CHANGED
@@ -1,17 +1,45 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: turbo_live
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- TheDumbTechGuy
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-10-
|
12
|
-
dependencies:
|
13
|
-
|
14
|
-
|
11
|
+
date: 2024-10-13 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: phlex-rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: literal
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description: Async, progressively enhanced, live components for Ruby applications
|
42
|
+
that work over Websockets and HTTP.
|
15
43
|
email:
|
16
44
|
- sfroelich01@gmail.com
|
17
45
|
executables: []
|
@@ -24,9 +52,27 @@ files:
|
|
24
52
|
- LICENSE.txt
|
25
53
|
- README.md
|
26
54
|
- Rakefile
|
55
|
+
- app/channels/components_channel.rb
|
56
|
+
- app/controllers/turbo_live/components_controller.rb
|
57
|
+
- config/routes.rb
|
58
|
+
- examples/countdown_component.rb
|
59
|
+
- examples/counter_component.rb
|
60
|
+
- examples/showcase_component.rb
|
61
|
+
- examples/tic_tac_toe_component.rb
|
27
62
|
- lib/turbo_live.rb
|
63
|
+
- lib/turbo_live/component.rb
|
64
|
+
- lib/turbo_live/engine.rb
|
65
|
+
- lib/turbo_live/renderer.rb
|
28
66
|
- lib/turbo_live/version.rb
|
67
|
+
- package-lock.json
|
68
|
+
- package.json
|
29
69
|
- sig/turbo_live.rbs
|
70
|
+
- src/.npmignore
|
71
|
+
- src/js/channels/register_channels.js
|
72
|
+
- src/js/channels/turbo_live_channel.js
|
73
|
+
- src/js/controllers/register_controllers.js
|
74
|
+
- src/js/controllers/turbo_live_controller.js
|
75
|
+
- src/js/core.js
|
30
76
|
homepage: https://github.com/radioactive-labs/turbo_live
|
31
77
|
licenses:
|
32
78
|
- MIT
|
@@ -53,5 +99,5 @@ requirements: []
|
|
53
99
|
rubygems_version: 3.5.16
|
54
100
|
signing_key:
|
55
101
|
specification_version: 4
|
56
|
-
summary:
|
102
|
+
summary: Async, progressively enhanced, live components for Ruby applications
|
57
103
|
test_files: []
|