turbo_live 0.1.0 → 0.1.2
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 +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: []
|