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.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +91 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +281 -0
  5. data/lib/generators/pulse_zero/install/install_generator.rb +186 -0
  6. data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/channel.rb.tt +6 -0
  7. data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/connection.rb.tt +59 -0
  8. data/lib/generators/pulse_zero/install/templates/backend/app/channels/pulse/channel.rb.tt +15 -0
  9. data/lib/generators/pulse_zero/install/templates/backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt +17 -0
  10. data/lib/generators/pulse_zero/install/templates/backend/app/jobs/pulse/broadcast_job.rb.tt +28 -0
  11. data/lib/generators/pulse_zero/install/templates/backend/app/models/concerns/pulse/broadcastable.rb.tt +85 -0
  12. data/lib/generators/pulse_zero/install/templates/backend/app/models/current.rb.tt +9 -0
  13. data/lib/generators/pulse_zero/install/templates/backend/config/initializers/pulse.rb.tt +43 -0
  14. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/engine.rb.tt +43 -0
  15. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/broadcasts.rb.tt +80 -0
  16. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/stream_name.rb.tt +34 -0
  17. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/thread_debouncer.rb.tt +31 -0
  18. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse.rb.tt +38 -0
  19. data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +532 -0
  20. data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-pulse.ts.tt +66 -0
  21. data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-visibility-refresh.ts.tt +61 -0
  22. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-connection.ts.tt +169 -0
  23. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-recovery-strategy.ts.tt +156 -0
  24. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-visibility-manager.ts.tt +143 -0
  25. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse.ts.tt +130 -0
  26. data/lib/pulse_zero/engine.rb +10 -0
  27. data/lib/pulse_zero/version.rb +5 -0
  28. data/lib/pulse_zero.rb +13 -0
  29. data/pulse_zero.gemspec +35 -0
  30. 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
+ }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PulseZero
4
+ # Only define the engine if Rails is loaded
5
+ if defined?(Rails::Engine)
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace PulseZero
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PulseZero
4
+ VERSION = "0.3.0"
5
+ end
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)
@@ -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