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
@@ -0,0 +1,169 @@
|
|
1
|
+
/**
|
2
|
+
* Pulse Connection Monitor
|
3
|
+
*
|
4
|
+
* Monitors WebSocket connection health and automatically reconnects when needed.
|
5
|
+
* Inspired by Turbo Rails' connection monitor but adapted for our needs.
|
6
|
+
*/
|
7
|
+
|
8
|
+
import { touchActivity, getLastActivity } from './pulse'
|
9
|
+
|
10
|
+
export interface ConnectionMonitorOptions {
|
11
|
+
staleThreshold: number // seconds before considering connection stale
|
12
|
+
reconnectInterval: number[] // exponential backoff intervals
|
13
|
+
}
|
14
|
+
|
15
|
+
const DEFAULT_OPTIONS: ConnectionMonitorOptions = {
|
16
|
+
staleThreshold: 12, // 12 seconds (2 ping intervals)
|
17
|
+
reconnectInterval: [3, 5, 10, 30, 60] // seconds
|
18
|
+
}
|
19
|
+
|
20
|
+
class PulseConnectionMonitor {
|
21
|
+
private options: ConnectionMonitorOptions
|
22
|
+
private reconnectAttempts = 0
|
23
|
+
private pollInterval: number | null = null
|
24
|
+
private visibilityDidChange = this.handleVisibilityChange.bind(this)
|
25
|
+
private isRunning = false
|
26
|
+
private lastMessageActivity = Date.now()
|
27
|
+
private lastReconnectAt = 0
|
28
|
+
private isConnecting = false
|
29
|
+
|
30
|
+
constructor(options: Partial<ConnectionMonitorOptions> = {}) {
|
31
|
+
this.options = { ...DEFAULT_OPTIONS, ...options }
|
32
|
+
}
|
33
|
+
|
34
|
+
start() {
|
35
|
+
if (this.isRunning) return
|
36
|
+
|
37
|
+
this.isRunning = true
|
38
|
+
this.startPolling()
|
39
|
+
this.setupEventListeners()
|
40
|
+
}
|
41
|
+
|
42
|
+
stop() {
|
43
|
+
if (!this.isRunning) return
|
44
|
+
|
45
|
+
this.isRunning = false
|
46
|
+
this.stopPolling()
|
47
|
+
this.removeEventListeners()
|
48
|
+
}
|
49
|
+
|
50
|
+
private startPolling() {
|
51
|
+
this.stopPolling()
|
52
|
+
this.poll()
|
53
|
+
this.pollInterval = window.setInterval(() => this.poll(), 6000) // 6 seconds
|
54
|
+
}
|
55
|
+
|
56
|
+
private stopPolling() {
|
57
|
+
if (this.pollInterval) {
|
58
|
+
clearInterval(this.pollInterval)
|
59
|
+
this.pollInterval = null
|
60
|
+
}
|
61
|
+
}
|
62
|
+
|
63
|
+
private poll() {
|
64
|
+
const now = Date.now()
|
65
|
+
const secondsSinceActivity = (now - getLastActivity()) / 1000
|
66
|
+
|
67
|
+
// Check if connection is stale
|
68
|
+
if (secondsSinceActivity > this.options.staleThreshold) {
|
69
|
+
this.reconnectIfNeeded()
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
private reconnectIfNeeded() {
|
74
|
+
if (this.isConnecting) return
|
75
|
+
|
76
|
+
const now = Date.now()
|
77
|
+
const timeSinceLastReconnect = now - this.lastReconnectAt
|
78
|
+
|
79
|
+
// Exponential backoff
|
80
|
+
const backoffTime = this.getReconnectInterval() * 1000
|
81
|
+
if (timeSinceLastReconnect < backoffTime) return
|
82
|
+
|
83
|
+
this.isConnecting = true
|
84
|
+
this.lastReconnectAt = now
|
85
|
+
this.reconnectAttempts++
|
86
|
+
|
87
|
+
// In a real implementation, you'd trigger ActionCable reconnection here
|
88
|
+
// For now, we'll just touch activity to simulate reconnection
|
89
|
+
setTimeout(() => {
|
90
|
+
touchActivity()
|
91
|
+
this.isConnecting = false
|
92
|
+
|
93
|
+
// Reset attempts on successful reconnection
|
94
|
+
const secondsSinceActivity = (Date.now() - getLastActivity()) / 1000
|
95
|
+
if (secondsSinceActivity < this.options.staleThreshold) {
|
96
|
+
this.reconnectAttempts = 0
|
97
|
+
}
|
98
|
+
}, 100)
|
99
|
+
}
|
100
|
+
|
101
|
+
private getReconnectInterval(): number {
|
102
|
+
const { reconnectInterval } = this.options
|
103
|
+
const index = Math.min(this.reconnectAttempts, reconnectInterval.length - 1)
|
104
|
+
return reconnectInterval[index]
|
105
|
+
}
|
106
|
+
|
107
|
+
private setupEventListeners() {
|
108
|
+
if (typeof document !== 'undefined') {
|
109
|
+
document.addEventListener('visibilitychange', this.visibilityDidChange)
|
110
|
+
window.addEventListener('focus', this.visibilityDidChange)
|
111
|
+
}
|
112
|
+
}
|
113
|
+
|
114
|
+
private removeEventListeners() {
|
115
|
+
if (typeof document !== 'undefined') {
|
116
|
+
document.removeEventListener('visibilitychange', this.visibilityDidChange)
|
117
|
+
window.removeEventListener('focus', this.visibilityDidChange)
|
118
|
+
}
|
119
|
+
}
|
120
|
+
|
121
|
+
private handleVisibilityChange() {
|
122
|
+
if (document.visibilityState === 'visible' || document.hasFocus()) {
|
123
|
+
// Immediately check connection when tab becomes visible
|
124
|
+
setTimeout(() => this.poll(), 200)
|
125
|
+
}
|
126
|
+
}
|
127
|
+
|
128
|
+
recordMessageActivity() {
|
129
|
+
this.lastMessageActivity = Date.now()
|
130
|
+
}
|
131
|
+
|
132
|
+
getStats() {
|
133
|
+
const now = Date.now()
|
134
|
+
return {
|
135
|
+
isRunning: this.isRunning,
|
136
|
+
isConnecting: this.isConnecting,
|
137
|
+
reconnectAttempts: this.reconnectAttempts,
|
138
|
+
secondsSinceActivity: (now - getLastActivity()) / 1000,
|
139
|
+
secondsSinceMessage: (now - this.lastMessageActivity) / 1000,
|
140
|
+
}
|
141
|
+
}
|
142
|
+
}
|
143
|
+
|
144
|
+
// Singleton instance
|
145
|
+
let monitor: PulseConnectionMonitor | null = null
|
146
|
+
|
147
|
+
export function startPulseMonitor(options?: Partial<ConnectionMonitorOptions>) {
|
148
|
+
if (!monitor) {
|
149
|
+
monitor = new PulseConnectionMonitor(options)
|
150
|
+
}
|
151
|
+
monitor.start()
|
152
|
+
return monitor
|
153
|
+
}
|
154
|
+
|
155
|
+
export function stopPulseMonitor() {
|
156
|
+
if (monitor) {
|
157
|
+
monitor.stop()
|
158
|
+
}
|
159
|
+
}
|
160
|
+
|
161
|
+
export function getPulseMonitorStats() {
|
162
|
+
return monitor?.getStats() || {
|
163
|
+
isRunning: false,
|
164
|
+
isConnecting: false,
|
165
|
+
reconnectAttempts: 0,
|
166
|
+
secondsSinceActivity: Infinity,
|
167
|
+
secondsSinceMessage: Infinity,
|
168
|
+
}
|
169
|
+
}
|
@@ -0,0 +1,156 @@
|
|
1
|
+
/**
|
2
|
+
* Pulse Recovery Strategy
|
3
|
+
*
|
4
|
+
* Implements platform-aware recovery strategies for handling browser tab suspension
|
5
|
+
* and connection issues. Based on patterns from Facebook, Twitter, and other major platforms.
|
6
|
+
*/
|
7
|
+
|
8
|
+
export interface RecoveryAction {
|
9
|
+
type: 'none' | 'reconnect' | 'sync' | 'refresh'
|
10
|
+
delay: number
|
11
|
+
reason: string
|
12
|
+
}
|
13
|
+
|
14
|
+
export class PulseRecoveryStrategy {
|
15
|
+
// Platform-specific thresholds (in seconds)
|
16
|
+
private static readonly THRESHOLDS = {
|
17
|
+
// Safari and mobile browsers are more aggressive with suspension
|
18
|
+
safari: {
|
19
|
+
quick: 15, // Quick tab switch
|
20
|
+
medium: 60, // Medium absence
|
21
|
+
long: 300 // Long absence (5 min)
|
22
|
+
},
|
23
|
+
// Desktop Chrome/Firefox are more lenient
|
24
|
+
default: {
|
25
|
+
quick: 30, // Quick tab switch
|
26
|
+
medium: 300, // Medium absence (5 min)
|
27
|
+
long: 900 // Long absence (15 min)
|
28
|
+
}
|
29
|
+
}
|
30
|
+
|
31
|
+
// Exponential backoff with jitter
|
32
|
+
private static readonly BACKOFF_BASE = 1000 // 1 second
|
33
|
+
private static readonly BACKOFF_MAX = 60000 // 60 seconds
|
34
|
+
private static readonly JITTER_FACTOR = 0.3
|
35
|
+
|
36
|
+
/**
|
37
|
+
* Determine the recovery action based on how long the tab was hidden
|
38
|
+
*/
|
39
|
+
static getRecoveryAction(hiddenDurationSeconds: number): RecoveryAction {
|
40
|
+
const thresholds = this.getCurrentThresholds()
|
41
|
+
|
42
|
+
if (hiddenDurationSeconds < thresholds.quick) {
|
43
|
+
// Quick switch - just ensure connection is alive
|
44
|
+
return {
|
45
|
+
type: 'none',
|
46
|
+
delay: 0,
|
47
|
+
reason: 'Quick tab switch, no recovery needed'
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
if (hiddenDurationSeconds < thresholds.medium) {
|
52
|
+
// Medium absence - reconnect and maybe sync
|
53
|
+
return {
|
54
|
+
type: 'sync',
|
55
|
+
delay: this.calculateDelay(1),
|
56
|
+
reason: `Hidden for ${Math.round(hiddenDurationSeconds)}s, syncing data`
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
// Long absence - full refresh for consistency
|
61
|
+
return {
|
62
|
+
type: 'refresh',
|
63
|
+
delay: this.calculateDelay(2),
|
64
|
+
reason: `Hidden for ${Math.round(hiddenDurationSeconds)}s, full refresh needed`
|
65
|
+
}
|
66
|
+
}
|
67
|
+
|
68
|
+
/**
|
69
|
+
* Get platform-specific thresholds
|
70
|
+
*/
|
71
|
+
private static getCurrentThresholds() {
|
72
|
+
if (typeof window === 'undefined') {
|
73
|
+
return this.THRESHOLDS.default
|
74
|
+
}
|
75
|
+
|
76
|
+
const ua = window.navigator.userAgent.toLowerCase()
|
77
|
+
|
78
|
+
// Safari detection (including iOS)
|
79
|
+
if (ua.includes('safari') && !ua.includes('chrome')) {
|
80
|
+
return this.THRESHOLDS.safari
|
81
|
+
}
|
82
|
+
|
83
|
+
// Mobile detection
|
84
|
+
if (/mobile|android|iphone|ipad/i.test(ua)) {
|
85
|
+
return this.THRESHOLDS.safari // Use aggressive thresholds for mobile
|
86
|
+
}
|
87
|
+
|
88
|
+
return this.THRESHOLDS.default
|
89
|
+
}
|
90
|
+
|
91
|
+
/**
|
92
|
+
* Calculate delay with exponential backoff and jitter
|
93
|
+
*/
|
94
|
+
private static calculateDelay(attemptNumber: number): number {
|
95
|
+
const exponentialDelay = Math.min(
|
96
|
+
this.BACKOFF_BASE * Math.pow(2, attemptNumber - 1),
|
97
|
+
this.BACKOFF_MAX
|
98
|
+
)
|
99
|
+
|
100
|
+
// Add jitter to prevent thundering herd
|
101
|
+
const jitter = exponentialDelay * this.JITTER_FACTOR * (Math.random() * 2 - 1)
|
102
|
+
|
103
|
+
return Math.round(exponentialDelay + jitter)
|
104
|
+
}
|
105
|
+
|
106
|
+
/**
|
107
|
+
* Get recommended visibility refresh threshold for the current platform
|
108
|
+
*/
|
109
|
+
static getRecommendedThreshold(): number {
|
110
|
+
const thresholds = this.getCurrentThresholds()
|
111
|
+
return thresholds.quick
|
112
|
+
}
|
113
|
+
|
114
|
+
/**
|
115
|
+
* Check if we should use aggressive recovery (for Safari/mobile)
|
116
|
+
*/
|
117
|
+
static shouldUseAggressiveRecovery(): boolean {
|
118
|
+
const thresholds = this.getCurrentThresholds()
|
119
|
+
return thresholds === this.THRESHOLDS.safari
|
120
|
+
}
|
121
|
+
|
122
|
+
/**
|
123
|
+
* Get human-readable platform detection info
|
124
|
+
*/
|
125
|
+
static getPlatformInfo(): {
|
126
|
+
platform: 'safari' | 'mobile' | 'desktop'
|
127
|
+
thresholds: typeof PulseRecoveryStrategy.THRESHOLDS.default
|
128
|
+
aggressive: boolean
|
129
|
+
} {
|
130
|
+
const thresholds = this.getCurrentThresholds()
|
131
|
+
const isSafari = thresholds === this.THRESHOLDS.safari
|
132
|
+
|
133
|
+
if (typeof window === 'undefined') {
|
134
|
+
return {
|
135
|
+
platform: 'desktop',
|
136
|
+
thresholds,
|
137
|
+
aggressive: false
|
138
|
+
}
|
139
|
+
}
|
140
|
+
|
141
|
+
const ua = window.navigator.userAgent.toLowerCase()
|
142
|
+
|
143
|
+
let platform: 'safari' | 'mobile' | 'desktop' = 'desktop'
|
144
|
+
if (ua.includes('safari') && !ua.includes('chrome')) {
|
145
|
+
platform = 'safari'
|
146
|
+
} else if (/mobile|android|iphone|ipad/i.test(ua)) {
|
147
|
+
platform = 'mobile'
|
148
|
+
}
|
149
|
+
|
150
|
+
return {
|
151
|
+
platform,
|
152
|
+
thresholds,
|
153
|
+
aggressive: isSafari
|
154
|
+
}
|
155
|
+
}
|
156
|
+
}
|
@@ -0,0 +1,143 @@
|
|
1
|
+
/**
|
2
|
+
* Pulse Visibility Manager
|
3
|
+
*
|
4
|
+
* Manages tab visibility state and triggers appropriate actions when tabs
|
5
|
+
* become visible after being hidden. Handles browser quirks and edge cases.
|
6
|
+
*/
|
7
|
+
|
8
|
+
export interface VisibilityManagerOptions {
|
9
|
+
onVisible?: () => void
|
10
|
+
onHidden?: () => void
|
11
|
+
onStale?: () => void
|
12
|
+
staleThreshold?: number // seconds before considering data stale
|
13
|
+
}
|
14
|
+
|
15
|
+
export class PulseVisibilityManager {
|
16
|
+
private hiddenAt: number | null = null
|
17
|
+
private isHidden = false
|
18
|
+
private callbacks: Required<VisibilityManagerOptions>
|
19
|
+
private checkStaleTimeout: number | null = null
|
20
|
+
|
21
|
+
constructor(options: VisibilityManagerOptions = {}) {
|
22
|
+
this.callbacks = {
|
23
|
+
onVisible: options.onVisible || (() => {}),
|
24
|
+
onHidden: options.onHidden || (() => {}),
|
25
|
+
onStale: options.onStale || (() => {}),
|
26
|
+
staleThreshold: options.staleThreshold || 30
|
27
|
+
}
|
28
|
+
|
29
|
+
this.handleVisibilityChange = this.handleVisibilityChange.bind(this)
|
30
|
+
this.handleFocus = this.handleFocus.bind(this)
|
31
|
+
this.handleBlur = this.handleBlur.bind(this)
|
32
|
+
|
33
|
+
this.setupListeners()
|
34
|
+
this.checkInitialState()
|
35
|
+
}
|
36
|
+
|
37
|
+
private setupListeners() {
|
38
|
+
if (typeof document !== 'undefined') {
|
39
|
+
// Primary: Page Visibility API
|
40
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
41
|
+
|
42
|
+
// Fallback: Focus/blur events
|
43
|
+
window.addEventListener('focus', this.handleFocus)
|
44
|
+
window.addEventListener('blur', this.handleBlur)
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
48
|
+
private checkInitialState() {
|
49
|
+
if (typeof document !== 'undefined') {
|
50
|
+
this.isHidden = document.hidden || !document.hasFocus()
|
51
|
+
if (this.isHidden) {
|
52
|
+
this.hiddenAt = Date.now()
|
53
|
+
}
|
54
|
+
}
|
55
|
+
}
|
56
|
+
|
57
|
+
private handleVisibilityChange() {
|
58
|
+
if (document.hidden) {
|
59
|
+
this.markHidden()
|
60
|
+
} else {
|
61
|
+
this.markVisible()
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
private handleFocus() {
|
66
|
+
// Only trigger if visibility API didn't already handle it
|
67
|
+
if (!document.hidden) {
|
68
|
+
this.markVisible()
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
private handleBlur() {
|
73
|
+
// Use a small delay to avoid false positives from quick focus changes
|
74
|
+
setTimeout(() => {
|
75
|
+
if (!document.hasFocus() && document.hidden) {
|
76
|
+
this.markHidden()
|
77
|
+
}
|
78
|
+
}, 100)
|
79
|
+
}
|
80
|
+
|
81
|
+
private markHidden() {
|
82
|
+
if (!this.isHidden) {
|
83
|
+
this.isHidden = true
|
84
|
+
this.hiddenAt = Date.now()
|
85
|
+
this.callbacks.onHidden()
|
86
|
+
|
87
|
+
// Clear any pending stale check
|
88
|
+
if (this.checkStaleTimeout) {
|
89
|
+
clearTimeout(this.checkStaleTimeout)
|
90
|
+
this.checkStaleTimeout = null
|
91
|
+
}
|
92
|
+
}
|
93
|
+
}
|
94
|
+
|
95
|
+
private markVisible() {
|
96
|
+
if (this.isHidden) {
|
97
|
+
this.isHidden = false
|
98
|
+
const hiddenDuration = this.hiddenAt ? (Date.now() - this.hiddenAt) / 1000 : 0
|
99
|
+
|
100
|
+
this.callbacks.onVisible()
|
101
|
+
|
102
|
+
// Check if data might be stale
|
103
|
+
if (hiddenDuration >= this.callbacks.staleThreshold) {
|
104
|
+
// Small delay to allow connection to stabilize
|
105
|
+
this.checkStaleTimeout = window.setTimeout(() => {
|
106
|
+
this.callbacks.onStale()
|
107
|
+
this.checkStaleTimeout = null
|
108
|
+
}, 500)
|
109
|
+
}
|
110
|
+
|
111
|
+
this.hiddenAt = null
|
112
|
+
}
|
113
|
+
}
|
114
|
+
|
115
|
+
getHiddenDuration(): number {
|
116
|
+
if (!this.isHidden || !this.hiddenAt) {
|
117
|
+
return 0
|
118
|
+
}
|
119
|
+
return (Date.now() - this.hiddenAt) / 1000
|
120
|
+
}
|
121
|
+
|
122
|
+
isCurrentlyHidden(): boolean {
|
123
|
+
return this.isHidden
|
124
|
+
}
|
125
|
+
|
126
|
+
cleanup() {
|
127
|
+
if (typeof document !== 'undefined') {
|
128
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
129
|
+
window.removeEventListener('focus', this.handleFocus)
|
130
|
+
window.removeEventListener('blur', this.handleBlur)
|
131
|
+
}
|
132
|
+
|
133
|
+
if (this.checkStaleTimeout) {
|
134
|
+
clearTimeout(this.checkStaleTimeout)
|
135
|
+
this.checkStaleTimeout = null
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
// Factory function for easier usage
|
141
|
+
export function createPulseVisibilityManager(options: VisibilityManagerOptions) {
|
142
|
+
return new PulseVisibilityManager(options)
|
143
|
+
}
|
@@ -0,0 +1,130 @@
|
|
1
|
+
import { createConsumer, Consumer, Subscription } from "@rails/actioncable"
|
2
|
+
|
3
|
+
export interface PulseMessage {
|
4
|
+
event: 'created' | 'updated' | 'deleted' | 'refresh'
|
5
|
+
payload: any
|
6
|
+
requestId?: string
|
7
|
+
at: number
|
8
|
+
}
|
9
|
+
|
10
|
+
export interface PulseSubscription {
|
11
|
+
unsubscribe: () => void
|
12
|
+
}
|
13
|
+
|
14
|
+
class PulseManager {
|
15
|
+
private consumer: Consumer | null = null
|
16
|
+
private subscriptions: Map<string, Subscription> = new Map()
|
17
|
+
private debug = false
|
18
|
+
|
19
|
+
constructor() {
|
20
|
+
// Enable debug logging if localStorage flag is set
|
21
|
+
if (typeof window !== 'undefined' && localStorage.getItem('PULSE_DEBUG') === 'true') {
|
22
|
+
this.debug = true
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
private log(message: string, data?: any) {
|
27
|
+
if (this.debug) {
|
28
|
+
console.log(`[Pulse] ${message}`, data || '')
|
29
|
+
}
|
30
|
+
}
|
31
|
+
|
32
|
+
private getConsumer(): Consumer {
|
33
|
+
if (!this.consumer) {
|
34
|
+
this.consumer = createConsumer()
|
35
|
+
this.log('Consumer created')
|
36
|
+
}
|
37
|
+
return this.consumer
|
38
|
+
}
|
39
|
+
|
40
|
+
subscribe(
|
41
|
+
signedStreamName: string,
|
42
|
+
onMessage: (message: PulseMessage) => void
|
43
|
+
): PulseSubscription {
|
44
|
+
const consumer = this.getConsumer()
|
45
|
+
|
46
|
+
// Unsubscribe from existing subscription if any
|
47
|
+
const existingSubscription = this.subscriptions.get(signedStreamName)
|
48
|
+
if (existingSubscription) {
|
49
|
+
existingSubscription.unsubscribe()
|
50
|
+
this.subscriptions.delete(signedStreamName)
|
51
|
+
this.log('Unsubscribed from existing subscription', signedStreamName)
|
52
|
+
}
|
53
|
+
|
54
|
+
// Create new subscription
|
55
|
+
const subscription = consumer.subscriptions.create(
|
56
|
+
{
|
57
|
+
channel: "Pulse::Channel",
|
58
|
+
"signed-stream-name": signedStreamName
|
59
|
+
},
|
60
|
+
{
|
61
|
+
connected: () => {
|
62
|
+
this.log('Subscription connected', signedStreamName)
|
63
|
+
touchActivity()
|
64
|
+
},
|
65
|
+
disconnected: () => {
|
66
|
+
this.log('Subscription disconnected', signedStreamName)
|
67
|
+
},
|
68
|
+
received: (data: string) => {
|
69
|
+
try {
|
70
|
+
touchActivity()
|
71
|
+
const message = JSON.parse(data) as PulseMessage
|
72
|
+
this.log('Message received', message)
|
73
|
+
onMessage(message)
|
74
|
+
} catch (error) {
|
75
|
+
console.error('[Pulse] Failed to parse message:', error)
|
76
|
+
}
|
77
|
+
}
|
78
|
+
}
|
79
|
+
)
|
80
|
+
|
81
|
+
this.subscriptions.set(signedStreamName, subscription)
|
82
|
+
|
83
|
+
return {
|
84
|
+
unsubscribe: () => {
|
85
|
+
subscription.unsubscribe()
|
86
|
+
this.subscriptions.delete(signedStreamName)
|
87
|
+
this.log('Subscription removed', signedStreamName)
|
88
|
+
}
|
89
|
+
}
|
90
|
+
}
|
91
|
+
|
92
|
+
disconnect() {
|
93
|
+
this.subscriptions.forEach(subscription => {
|
94
|
+
subscription.unsubscribe()
|
95
|
+
})
|
96
|
+
this.subscriptions.clear()
|
97
|
+
|
98
|
+
if (this.consumer) {
|
99
|
+
this.consumer.disconnect()
|
100
|
+
this.consumer = null
|
101
|
+
this.log('Consumer disconnected')
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
|
106
|
+
// Singleton instance
|
107
|
+
const pulseManager = new PulseManager()
|
108
|
+
|
109
|
+
// Export functions
|
110
|
+
export function subscribeToPulse(
|
111
|
+
signedStreamName: string,
|
112
|
+
onMessage: (message: PulseMessage) => void
|
113
|
+
): PulseSubscription {
|
114
|
+
return pulseManager.subscribe(signedStreamName, onMessage)
|
115
|
+
}
|
116
|
+
|
117
|
+
export function disconnectPulse() {
|
118
|
+
pulseManager.disconnect()
|
119
|
+
}
|
120
|
+
|
121
|
+
// Activity tracking for connection monitoring
|
122
|
+
let lastActivity = Date.now()
|
123
|
+
|
124
|
+
export function touchActivity() {
|
125
|
+
lastActivity = Date.now()
|
126
|
+
}
|
127
|
+
|
128
|
+
export function getLastActivity(): number {
|
129
|
+
return lastActivity
|
130
|
+
}
|
data/lib/pulse_zero.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "pulse_zero/version"
|
4
|
+
|
5
|
+
module PulseZero
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
# Autoload the engine only when Rails is available
|
9
|
+
autoload :Engine, "pulse_zero/engine"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Load the engine if Rails is already loaded
|
13
|
+
require_relative "pulse_zero/engine" if defined?(Rails)
|
data/pulse_zero.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/pulse_zero/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "pulse_zero"
|
7
|
+
spec.version = PulseZero::VERSION
|
8
|
+
spec.authors = ["darkamenosa"]
|
9
|
+
spec.email = ["hxtxmu@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "Real-time broadcasting generator for Rails + Inertia"
|
12
|
+
spec.description = "Generate a complete real-time broadcasting system for Rails applications using " \
|
13
|
+
"Inertia.js with React. All code is generated into your project with zero runtime dependencies."
|
14
|
+
spec.homepage = "https://github.com/darkamenosa/pulse_zero"
|
15
|
+
spec.license = "MIT"
|
16
|
+
spec.required_ruby_version = ">= 3.0.0"
|
17
|
+
|
18
|
+
# Use only unique URIs for each metadata key
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main"
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
22
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
23
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
24
|
+
|
25
|
+
spec.files = Dir.glob("{lib,exe}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } +
|
26
|
+
%w[README.md LICENSE.txt CHANGELOG.md pulse_zero.gemspec]
|
27
|
+
spec.bindir = "exe"
|
28
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
|
31
|
+
spec.add_dependency "railties", ">= 7.0", "< 9"
|
32
|
+
spec.add_dependency "thor", "~> 1.0"
|
33
|
+
|
34
|
+
# Development dependencies are now managed in Gemfile
|
35
|
+
end
|