pulse_zero 0.3.0
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/CHANGELOG.md +91 -0
- data/LICENSE.txt +21 -0
- data/README.md +281 -0
- data/lib/generators/pulse_zero/install/install_generator.rb +186 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/channel.rb.tt +6 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/connection.rb.tt +59 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/pulse/channel.rb.tt +15 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt +17 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/jobs/pulse/broadcast_job.rb.tt +28 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/models/concerns/pulse/broadcastable.rb.tt +85 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/models/current.rb.tt +9 -0
- data/lib/generators/pulse_zero/install/templates/backend/config/initializers/pulse.rb.tt +43 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/engine.rb.tt +43 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/broadcasts.rb.tt +80 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/stream_name.rb.tt +34 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/thread_debouncer.rb.tt +31 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse.rb.tt +38 -0
- data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +532 -0
- data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-pulse.ts.tt +66 -0
- data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-visibility-refresh.ts.tt +61 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-connection.ts.tt +169 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-recovery-strategy.ts.tt +156 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-visibility-manager.ts.tt +143 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse.ts.tt +130 -0
- data/lib/pulse_zero/engine.rb +10 -0
- data/lib/pulse_zero/version.rb +5 -0
- data/lib/pulse_zero.rb +13 -0
- data/pulse_zero.gemspec +35 -0
- metadata +109 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bc39a93099909f808b4bde3b5cfb4826be34d8feb9c1c71eb5e301e9527b2679
|
4
|
+
data.tar.gz: dba7ce1f61b85c4cef989e84c1b6c3c7354cdcad8eff69572e141a9d39640fef
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 496b91b569c09f96f1aad6844811f73e206a5ccdecaa7c9d02aef428f03386c2c0661a85a0b4e12a67416294794810e0da91c73d42d584b29c1f903a9aa61f6e
|
7
|
+
data.tar.gz: 860817c4a47cc4e66ff484df3ef3f148ac31699bc7d67dd34804f7e0c972617740e4e85dea7773d5ad77270d0a890c333356f79631b023c5e20b68a73f6fe5e7
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [Unreleased]
|
9
|
+
|
10
|
+
## [0.3.0] - 2025-01-26
|
11
|
+
|
12
|
+
### Changed
|
13
|
+
- Moved all frontend library files from `app/frontend/lib/` to `app/frontend/lib/pulse/` subdirectory
|
14
|
+
- Updated all import paths in hooks to reference the new location
|
15
|
+
- This provides better organization and avoids naming conflicts with other libraries
|
16
|
+
|
17
|
+
### Fixed
|
18
|
+
- Updated documentation to reflect new import paths
|
19
|
+
|
20
|
+
## [0.2.2] - 2025-01-26
|
21
|
+
|
22
|
+
### Fixed
|
23
|
+
- Fixed WebSocket subscription parameter name in pulse.ts (changed from `signed_stream_name` to `"signed-stream-name"`)
|
24
|
+
- This fixes the issue where broadcasts were sent but not received by the frontend
|
25
|
+
|
26
|
+
## [0.2.1] - 2025-01-26
|
27
|
+
|
28
|
+
### Fixed
|
29
|
+
- Fixed "uninitialized constant Pulse" error on Rails startup
|
30
|
+
- Generator now properly sets up autoload paths for lib directory
|
31
|
+
- Made initializer defensive by checking if Pulse is defined
|
32
|
+
- Removed conditional engine loading from pulse.rb template
|
33
|
+
- Added setup_autoload_paths step to generator execution flow
|
34
|
+
- Fixed ApplicationCable connection to not assume authentication is configured
|
35
|
+
- Default connection now accepts all connections with guest identifiers
|
36
|
+
|
37
|
+
### Changed
|
38
|
+
- ApplicationCable::Connection template now provides safe default that accepts all connections
|
39
|
+
- Added comprehensive authentication examples (Devise, Session, JWT) in documentation
|
40
|
+
- Generator now warns about authentication configuration requirement
|
41
|
+
- Improved documentation with authentication setup section
|
42
|
+
|
43
|
+
## [0.2.0] - 2025-01-26
|
44
|
+
|
45
|
+
### Changed
|
46
|
+
- Complete rewrite of templates to match actual implementation
|
47
|
+
- Use `mattr_accessor :config` instead of thread-local variables
|
48
|
+
- Use `Pulse.config` for all configuration
|
49
|
+
- Match exact broadcast API with keyword arguments
|
50
|
+
- BroadcastJob now accepts named parameters (streamables:, event:, payload:, request_id:)
|
51
|
+
- ThreadDebouncer uses instance-based approach with `.for(key)`
|
52
|
+
- StreamName module uses `extend self` pattern
|
53
|
+
- Channel uses hyphenated parameter names ("signed-stream-name")
|
54
|
+
- Documentation shows both direct broadcasting and DSL approaches
|
55
|
+
|
56
|
+
### Fixed
|
57
|
+
- Exact match with production code patterns
|
58
|
+
- Proper configuration in initializer using Pulse.config
|
59
|
+
- Correct parameter passing to broadcast methods
|
60
|
+
|
61
|
+
## [0.1.1] - 2025-01-26
|
62
|
+
|
63
|
+
### Fixed
|
64
|
+
- Create `app/frontend/types/index.ts` if it doesn't exist instead of failing
|
65
|
+
- Prevent duplicate injection of `pulse_request_id` in Current model
|
66
|
+
- Prevent duplicate inclusion of `Pulse::RequestIdTracking` in ApplicationController
|
67
|
+
|
68
|
+
## [0.1.0] - 2025-01-26
|
69
|
+
|
70
|
+
### Added
|
71
|
+
- Initial release of Pulse Zero
|
72
|
+
- Rails generator for installing real-time broadcasting system
|
73
|
+
- Backend components:
|
74
|
+
- Core Pulse module with stream verification
|
75
|
+
- Rails Engine for isolation
|
76
|
+
- Broadcasting system with CRUD events
|
77
|
+
- Model concern with `broadcasts_to` DSL
|
78
|
+
- ActionCable channel for WebSocket subscriptions
|
79
|
+
- Background job for async broadcasting
|
80
|
+
- Request ID tracking for correlation
|
81
|
+
- Frontend components (TypeScript):
|
82
|
+
- Subscription manager
|
83
|
+
- Connection monitor with exponential backoff
|
84
|
+
- Recovery strategy for browser tab suspension
|
85
|
+
- Visibility manager for tab focus handling
|
86
|
+
- React hooks: `usePulse` and `useVisibilityRefresh`
|
87
|
+
- Comprehensive documentation
|
88
|
+
- Test helpers and examples
|
89
|
+
|
90
|
+
[Unreleased]: https://github.com/yourusername/pulse_zero/compare/v0.1.0...HEAD
|
91
|
+
[0.1.0]: https://github.com/yourusername/pulse_zero/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Pulse Zero Contributors
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,281 @@
|
|
1
|
+
# Pulse Zero
|
2
|
+
|
3
|
+
Real-time broadcasting generator for Rails + Inertia.js applications. Generate a complete WebSocket-based real-time system with zero runtime dependencies.
|
4
|
+
|
5
|
+
## What is Pulse Zero?
|
6
|
+
|
7
|
+
Pulse Zero generates a complete real-time broadcasting system directly into your Rails application. Unlike traditional gems, all code is copied into your project, giving you full ownership and the ability to customize everything.
|
8
|
+
|
9
|
+
Features:
|
10
|
+
- 🚀 WebSocket broadcasting via ActionCable
|
11
|
+
- 🔒 Secure signed streams
|
12
|
+
- 📱 Browser tab suspension handling
|
13
|
+
- 🔄 Automatic reconnection with exponential backoff
|
14
|
+
- 📦 TypeScript support for Inertia + React
|
15
|
+
- 🎯 Zero runtime dependencies
|
16
|
+
|
17
|
+
## Installation
|
18
|
+
|
19
|
+
Add this gem to your application's Gemfile:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
group :development do
|
23
|
+
gem 'pulse_zero'
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
Then run:
|
28
|
+
|
29
|
+
```bash
|
30
|
+
bundle install
|
31
|
+
rails generate pulse_zero:install
|
32
|
+
```
|
33
|
+
|
34
|
+
## What Gets Generated?
|
35
|
+
|
36
|
+
### Backend (Ruby)
|
37
|
+
- `lib/pulse/` - Core broadcasting system
|
38
|
+
- `app/models/concerns/pulse/broadcastable.rb` - Model broadcasting DSL
|
39
|
+
- `app/controllers/concerns/pulse/request_id_tracking.rb` - Request tracking
|
40
|
+
- `app/channels/pulse/channel.rb` - WebSocket channel
|
41
|
+
- `app/jobs/pulse/broadcast_job.rb` - Async broadcasting
|
42
|
+
- `config/initializers/pulse.rb` - Configuration
|
43
|
+
|
44
|
+
### Frontend (TypeScript)
|
45
|
+
- `app/frontend/lib/pulse.ts` - Subscription manager
|
46
|
+
- `app/frontend/lib/pulse-connection.ts` - Connection monitoring
|
47
|
+
- `app/frontend/lib/pulse-recovery-strategy.ts` - Recovery logic
|
48
|
+
- `app/frontend/lib/pulse-visibility-manager.ts` - Tab visibility handling
|
49
|
+
- `app/frontend/hooks/use-pulse.ts` - React subscription hook
|
50
|
+
- `app/frontend/hooks/use-visibility-refresh.ts` - Tab refresh hook
|
51
|
+
|
52
|
+
## Quick Start
|
53
|
+
|
54
|
+
### 1. Enable Broadcasting on a Model
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class Post < ApplicationRecord
|
58
|
+
include Pulse::Broadcastable
|
59
|
+
|
60
|
+
# Broadcast to account-scoped channel
|
61
|
+
broadcasts_to ->(post) { [post.account, "posts"] }
|
62
|
+
|
63
|
+
# Or broadcast to a simple channel
|
64
|
+
broadcasts "posts"
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
### 2. Pass Stream Token to Frontend
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
class PostsController < ApplicationController
|
72
|
+
def index
|
73
|
+
@posts = Current.account.posts
|
74
|
+
@pulse_stream = Pulse::Streams::StreamName
|
75
|
+
.signed_stream_name([Current.account, "posts"])
|
76
|
+
end
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
### 3. Subscribe in React Component
|
81
|
+
|
82
|
+
```tsx
|
83
|
+
import { usePulse } from '@/hooks/use-pulse'
|
84
|
+
import { useVisibilityRefresh } from '@/hooks/use-visibility-refresh'
|
85
|
+
import { router } from '@inertiajs/react'
|
86
|
+
|
87
|
+
export default function Posts({ posts, pulseStream }) {
|
88
|
+
// Handle tab visibility
|
89
|
+
useVisibilityRefresh(30, () => {
|
90
|
+
router.reload({ only: ['posts'] })
|
91
|
+
})
|
92
|
+
|
93
|
+
// Subscribe to real-time updates
|
94
|
+
usePulse(pulseStream, (message) => {
|
95
|
+
switch (message.event) {
|
96
|
+
case 'created':
|
97
|
+
case 'updated':
|
98
|
+
case 'deleted':
|
99
|
+
router.reload({ only: ['posts'] })
|
100
|
+
break
|
101
|
+
}
|
102
|
+
})
|
103
|
+
|
104
|
+
return <PostsList posts={posts} />
|
105
|
+
}
|
106
|
+
```
|
107
|
+
|
108
|
+
## Broadcasting Events
|
109
|
+
|
110
|
+
Pulse broadcasts four types of events:
|
111
|
+
|
112
|
+
### `created` - When a record is created
|
113
|
+
```json
|
114
|
+
{
|
115
|
+
"event": "created",
|
116
|
+
"payload": { "id": 123, "content": "New post" },
|
117
|
+
"requestId": "uuid-123",
|
118
|
+
"at": 1234567890.123
|
119
|
+
}
|
120
|
+
```
|
121
|
+
|
122
|
+
### `updated` - When a record is updated
|
123
|
+
```json
|
124
|
+
{
|
125
|
+
"event": "updated",
|
126
|
+
"payload": { "id": 123, "content": "Updated post" },
|
127
|
+
"requestId": "uuid-456",
|
128
|
+
"at": 1234567891.456
|
129
|
+
}
|
130
|
+
```
|
131
|
+
|
132
|
+
### `deleted` - When a record is destroyed
|
133
|
+
```json
|
134
|
+
{
|
135
|
+
"event": "deleted",
|
136
|
+
"payload": { "id": 123 },
|
137
|
+
"requestId": "uuid-789",
|
138
|
+
"at": 1234567892.789
|
139
|
+
}
|
140
|
+
```
|
141
|
+
|
142
|
+
### `refresh` - Force a full refresh
|
143
|
+
```json
|
144
|
+
{
|
145
|
+
"event": "refresh",
|
146
|
+
"payload": {},
|
147
|
+
"requestId": "uuid-012",
|
148
|
+
"at": 1234567893.012
|
149
|
+
}
|
150
|
+
```
|
151
|
+
|
152
|
+
## Advanced Usage
|
153
|
+
|
154
|
+
### Manual Broadcasting
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
# Broadcast with custom payload
|
158
|
+
post.broadcast_updated_to(
|
159
|
+
[Current.account, "posts"],
|
160
|
+
payload: { id: post.id, featured: true }
|
161
|
+
)
|
162
|
+
|
163
|
+
# Async broadcasting
|
164
|
+
post.broadcast_updated_later_to([Current.account, "posts"])
|
165
|
+
```
|
166
|
+
|
167
|
+
### Suppress Broadcasts During Bulk Operations
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
Post.suppressing_pulse_broadcasts do
|
171
|
+
Post.where(account: account).update_all(featured: true)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Then send one refresh broadcast
|
175
|
+
Post.new.broadcast_refresh_to([account, "posts"])
|
176
|
+
```
|
177
|
+
|
178
|
+
### Custom Serialization
|
179
|
+
|
180
|
+
```ruby
|
181
|
+
# config/initializers/pulse.rb
|
182
|
+
Rails.application.configure do
|
183
|
+
config.pulse.serializer = ->(record) {
|
184
|
+
case record
|
185
|
+
when Post
|
186
|
+
record.as_json(only: [:id, :title, :state])
|
187
|
+
else
|
188
|
+
record.as_json
|
189
|
+
end
|
190
|
+
}
|
191
|
+
end
|
192
|
+
```
|
193
|
+
|
194
|
+
## Configuration
|
195
|
+
|
196
|
+
```ruby
|
197
|
+
# config/initializers/pulse.rb
|
198
|
+
Rails.application.configure do
|
199
|
+
# Debounce window in milliseconds (default: 300)
|
200
|
+
config.pulse.debounce_ms = 300
|
201
|
+
|
202
|
+
# Background job queue (default: :default)
|
203
|
+
config.pulse.queue_name = :low
|
204
|
+
|
205
|
+
# Custom serializer
|
206
|
+
config.pulse.serializer = ->(record) { record.as_json }
|
207
|
+
end
|
208
|
+
```
|
209
|
+
|
210
|
+
## Browser Tab Handling
|
211
|
+
|
212
|
+
Pulse includes sophisticated handling for browser tab suspension:
|
213
|
+
|
214
|
+
- **Quick switches (<30s)**: Just ensures connection is alive
|
215
|
+
- **Medium absence (30s-5min)**: Reconnects and syncs data
|
216
|
+
- **Long absence (>5min)**: Full page refresh for consistency
|
217
|
+
|
218
|
+
Platform-aware thresholds:
|
219
|
+
- Desktop Chrome/Firefox: 30 seconds
|
220
|
+
- Safari/Mobile: 15 seconds (more aggressive)
|
221
|
+
|
222
|
+
## Testing
|
223
|
+
|
224
|
+
```ruby
|
225
|
+
# In your test files
|
226
|
+
test "broadcasts on update" do
|
227
|
+
post = posts(:one)
|
228
|
+
|
229
|
+
assert_broadcast_on([post.account, "posts"]) do
|
230
|
+
post.update!(title: "New Title")
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Suppress broadcasts in tests
|
235
|
+
Post.suppressing_pulse_broadcasts do
|
236
|
+
# Your test code
|
237
|
+
end
|
238
|
+
```
|
239
|
+
|
240
|
+
## Debugging
|
241
|
+
|
242
|
+
Enable debug logging:
|
243
|
+
|
244
|
+
```javascript
|
245
|
+
// In browser console
|
246
|
+
localStorage.setItem('PULSE_DEBUG', 'true')
|
247
|
+
```
|
248
|
+
|
249
|
+
Check connection health:
|
250
|
+
|
251
|
+
```javascript
|
252
|
+
import { getPulseMonitorStats } from '@/lib/pulse-connection'
|
253
|
+
|
254
|
+
const stats = getPulseMonitorStats()
|
255
|
+
console.log(stats)
|
256
|
+
```
|
257
|
+
|
258
|
+
## Requirements
|
259
|
+
|
260
|
+
- Rails 7.0+
|
261
|
+
- ActionCable
|
262
|
+
- Inertia.js
|
263
|
+
- React (Vue/Svelte support coming soon)
|
264
|
+
- TypeScript
|
265
|
+
|
266
|
+
## Philosophy
|
267
|
+
|
268
|
+
Pulse Zero follows the same philosophy as [authentication-zero](https://github.com/lazaronixon/authentication-zero):
|
269
|
+
|
270
|
+
- **Own your code**: All code is generated into your project
|
271
|
+
- **No runtime dependencies**: The gem is only needed during generation
|
272
|
+
- **Customizable**: Modify any generated code to fit your needs
|
273
|
+
- **Production-ready**: Includes battle-tested patterns from real applications
|
274
|
+
|
275
|
+
## Contributing
|
276
|
+
|
277
|
+
Bug reports and pull requests are welcome on GitHub.
|
278
|
+
|
279
|
+
## License
|
280
|
+
|
281
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,186 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
require "rails/generators/base"
|
5
|
+
|
6
|
+
module PulseZero
|
7
|
+
module Generators
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
10
|
+
|
11
|
+
def check_prerequisites
|
12
|
+
unless defined?(ActionCable)
|
13
|
+
say "ActionCable is required. Installing...", :yellow
|
14
|
+
generate "action_cable:install"
|
15
|
+
end
|
16
|
+
|
17
|
+
return if File.exist?("app/frontend")
|
18
|
+
|
19
|
+
say "This generator is designed for Inertia.js applications with app/frontend directory", :red
|
20
|
+
exit 1
|
21
|
+
end
|
22
|
+
|
23
|
+
def setup_action_cable
|
24
|
+
# Generate base channel classes if missing
|
25
|
+
unless File.exist?("app/channels/application_cable/connection.rb")
|
26
|
+
template "backend/app/channels/application_cable/connection.rb.tt",
|
27
|
+
"app/channels/application_cable/connection.rb"
|
28
|
+
end
|
29
|
+
|
30
|
+
unless File.exist?("app/channels/application_cable/channel.rb")
|
31
|
+
template "backend/app/channels/application_cable/channel.rb.tt",
|
32
|
+
"app/channels/application_cable/channel.rb"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Add route if missing
|
36
|
+
route 'mount ActionCable.server => "/cable"' unless action_cable_route_exists?
|
37
|
+
end
|
38
|
+
|
39
|
+
def copy_backend_files
|
40
|
+
# Core library files
|
41
|
+
template "backend/lib/pulse.rb.tt", "lib/pulse.rb"
|
42
|
+
template "backend/lib/pulse/engine.rb.tt", "lib/pulse/engine.rb"
|
43
|
+
template "backend/lib/pulse/streams/broadcasts.rb.tt", "lib/pulse/streams/broadcasts.rb"
|
44
|
+
template "backend/lib/pulse/streams/stream_name.rb.tt", "lib/pulse/streams/stream_name.rb"
|
45
|
+
template "backend/lib/pulse/thread_debouncer.rb.tt", "lib/pulse/thread_debouncer.rb"
|
46
|
+
|
47
|
+
# Application files
|
48
|
+
template "backend/app/channels/pulse/channel.rb.tt", "app/channels/pulse/channel.rb"
|
49
|
+
template "backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt",
|
50
|
+
"app/controllers/concerns/pulse/request_id_tracking.rb"
|
51
|
+
template "backend/app/models/concerns/pulse/broadcastable.rb.tt",
|
52
|
+
"app/models/concerns/pulse/broadcastable.rb"
|
53
|
+
template "backend/app/jobs/pulse/broadcast_job.rb.tt", "app/jobs/pulse/broadcast_job.rb"
|
54
|
+
|
55
|
+
# Configuration
|
56
|
+
template "backend/config/initializers/pulse.rb.tt", "config/initializers/pulse.rb"
|
57
|
+
end
|
58
|
+
|
59
|
+
def copy_frontend_files
|
60
|
+
# TypeScript files in lib/pulse/
|
61
|
+
template "frontend/lib/pulse.ts.tt", "app/frontend/lib/pulse/pulse.ts"
|
62
|
+
template "frontend/lib/pulse-connection.ts.tt", "app/frontend/lib/pulse/pulse-connection.ts"
|
63
|
+
template "frontend/lib/pulse-recovery-strategy.ts.tt", "app/frontend/lib/pulse/pulse-recovery-strategy.ts"
|
64
|
+
template "frontend/lib/pulse-visibility-manager.ts.tt", "app/frontend/lib/pulse/pulse-visibility-manager.ts"
|
65
|
+
|
66
|
+
# React hooks
|
67
|
+
template "frontend/hooks/use-pulse.ts.tt", "app/frontend/hooks/use-pulse.ts"
|
68
|
+
template "frontend/hooks/use-visibility-refresh.ts.tt", "app/frontend/hooks/use-visibility-refresh.ts"
|
69
|
+
|
70
|
+
# Create or append to types/index.ts
|
71
|
+
if File.exist?("app/frontend/types/index.ts")
|
72
|
+
append_to_file "app/frontend/types/index.ts", pulse_types_content
|
73
|
+
else
|
74
|
+
create_file "app/frontend/types/index.ts", pulse_types_content
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def setup_current_model
|
79
|
+
if File.exist?("app/models/current.rb")
|
80
|
+
# Check if pulse_request_id is already defined
|
81
|
+
current_content = File.read("app/models/current.rb")
|
82
|
+
unless current_content.include?("pulse_request_id")
|
83
|
+
inject_into_class "app/models/current.rb", "Current" do
|
84
|
+
" attribute :pulse_request_id\n"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
else
|
88
|
+
template "backend/app/models/current.rb.tt", "app/models/current.rb"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def setup_autoload_paths
|
93
|
+
add_pulse_to_autoload_paths
|
94
|
+
end
|
95
|
+
|
96
|
+
def install_npm_dependencies
|
97
|
+
return unless File.exist?("package.json")
|
98
|
+
|
99
|
+
say "Installing @rails/actioncable...", :green
|
100
|
+
run "npm install @rails/actioncable"
|
101
|
+
end
|
102
|
+
|
103
|
+
def add_pulse_to_autoload_paths
|
104
|
+
# Add lib to autoload paths
|
105
|
+
application_file = "config/application.rb"
|
106
|
+
application_content = File.read(application_file)
|
107
|
+
|
108
|
+
# Add to autoload_lib if not already there
|
109
|
+
return if application_content.include?("config.autoload_lib")
|
110
|
+
|
111
|
+
inject_into_file application_file, after: /class Application < Rails::Application\n/ do
|
112
|
+
<<-RUBY
|
113
|
+
# Autoload lib directory
|
114
|
+
config.autoload_lib(ignore: %w[assets tasks])
|
115
|
+
|
116
|
+
RUBY
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def setup_application_controller
|
121
|
+
# Check if already included
|
122
|
+
controller_content = File.read("app/controllers/application_controller.rb")
|
123
|
+
return if controller_content.include?("Pulse::RequestIdTracking")
|
124
|
+
|
125
|
+
inject_into_class "app/controllers/application_controller.rb",
|
126
|
+
"ApplicationController" do
|
127
|
+
" include Pulse::RequestIdTracking\n"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def create_documentation
|
132
|
+
template "docs/PULSE_USAGE.md.tt", "docs/PULSE_USAGE.md"
|
133
|
+
|
134
|
+
say "\n✅ Pulse real-time broadcasting has been installed!", :green
|
135
|
+
say "\n⚠️ IMPORTANT: Configure authentication!", :yellow
|
136
|
+
say "The default ApplicationCable connection accepts all connections."
|
137
|
+
say "Edit app/channels/application_cable/connection.rb to add your authentication logic."
|
138
|
+
say "\nNext steps:", :yellow
|
139
|
+
say "1. Configure authentication in app/channels/application_cable/connection.rb"
|
140
|
+
say "2. Read docs/PULSE_USAGE.md for complete setup instructions"
|
141
|
+
say "3. Add 'include Pulse::Broadcastable' to models that need broadcasting"
|
142
|
+
say "4. Use 'usePulse' hook in your React components"
|
143
|
+
say "\nExample:", :blue
|
144
|
+
say <<~EXAMPLE
|
145
|
+
# In your model:
|
146
|
+
class Post < ApplicationRecord
|
147
|
+
include Pulse::Broadcastable
|
148
|
+
broadcasts_to ->(post) { [post.account, "posts"] }
|
149
|
+
end
|
150
|
+
|
151
|
+
# In your controller:
|
152
|
+
@pulse_stream = Pulse::Streams::StreamName.signed_stream_name([Current.account, "posts"])
|
153
|
+
|
154
|
+
# In your React component:
|
155
|
+
usePulse(pulseStream, (message) => {
|
156
|
+
router.reload({ only: ['posts'] })
|
157
|
+
})
|
158
|
+
EXAMPLE
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def action_cable_route_exists?
|
164
|
+
routes_file = File.read("config/routes.rb")
|
165
|
+
routes_file.include?("ActionCable.server") || routes_file.include?("action_cable")
|
166
|
+
end
|
167
|
+
|
168
|
+
def pulse_types_content
|
169
|
+
<<~TYPES
|
170
|
+
|
171
|
+
// Pulse Types
|
172
|
+
export interface PulseMessage {
|
173
|
+
event: 'created' | 'updated' | 'deleted' | 'refresh'
|
174
|
+
payload: any
|
175
|
+
requestId?: string
|
176
|
+
at: number
|
177
|
+
}
|
178
|
+
|
179
|
+
export interface PulseSubscription {
|
180
|
+
unsubscribe: () => void
|
181
|
+
}
|
182
|
+
TYPES
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApplicationCable
|
4
|
+
class Connection < ActionCable::Connection::Base
|
5
|
+
identified_by :current_user
|
6
|
+
|
7
|
+
def connect
|
8
|
+
self.current_user = find_verified_user
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def find_verified_user
|
14
|
+
# Default implementation that accepts all connections
|
15
|
+
# Replace this with your authentication logic
|
16
|
+
|
17
|
+
# Example 1: Allow all connections (development/testing)
|
18
|
+
# Return a simple identifier for the connection
|
19
|
+
"guest_#{SecureRandom.hex(8)}"
|
20
|
+
|
21
|
+
# Example 2: Devise authentication (if using Devise)
|
22
|
+
# if verified_user = env["warden"]&.user
|
23
|
+
# verified_user
|
24
|
+
# else
|
25
|
+
# reject_unauthorized_connection
|
26
|
+
# end
|
27
|
+
|
28
|
+
# Example 3: Session-based authentication
|
29
|
+
# if session[:user_id] && verified_user = User.find_by(id: session[:user_id])
|
30
|
+
# verified_user
|
31
|
+
# else
|
32
|
+
# reject_unauthorized_connection
|
33
|
+
# end
|
34
|
+
|
35
|
+
# Example 4: JWT token authentication
|
36
|
+
# if verified_user = User.find_by(id: decoded_jwt_user_id)
|
37
|
+
# verified_user
|
38
|
+
# else
|
39
|
+
# reject_unauthorized_connection
|
40
|
+
# end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Helper method for JWT authentication (uncomment if needed)
|
44
|
+
# def decoded_jwt_user_id
|
45
|
+
# token = request.params[:token] || request.headers["Authorization"]&.split(" ")&.last
|
46
|
+
# return unless token
|
47
|
+
#
|
48
|
+
# decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: "HS256")
|
49
|
+
# decoded.first["user_id"]
|
50
|
+
# rescue JWT::DecodeError
|
51
|
+
# nil
|
52
|
+
# end
|
53
|
+
|
54
|
+
# Helper method for session access (uncomment if needed)
|
55
|
+
# def session
|
56
|
+
# cookies.encrypted[Rails.application.config.session_options[:key]]
|
57
|
+
# end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Pulse::Channel < ApplicationCable::Channel
|
4
|
+
include Pulse::Streams::StreamName::ClassMethods
|
5
|
+
|
6
|
+
def subscribed
|
7
|
+
signed_name = params["signed-stream-name"]
|
8
|
+
if signed_name && verified_stream_name_from_params
|
9
|
+
# Stream from the signed name (same as what broadcaster uses)
|
10
|
+
stream_from signed_name
|
11
|
+
else
|
12
|
+
reject
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pulse
|
4
|
+
module RequestIdTracking
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
before_action :set_pulse_request_id
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def set_pulse_request_id
|
14
|
+
Current.pulse_request_id = request.uuid if defined?(Current) && Current.respond_to?(:pulse_request_id=)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|