live_cable 0.0.1 → 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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +1275 -0
  4. data/app/assets/javascript/controllers/live_controller.js +79 -44
  5. data/app/assets/javascript/dom.js +161 -0
  6. data/app/assets/javascript/live_cable.js +50 -0
  7. data/app/assets/javascript/live_cable_blessing.js +1 -1
  8. data/app/assets/javascript/observer.js +74 -0
  9. data/app/assets/javascript/subscriptions.js +396 -37
  10. data/app/channels/live_channel.rb +17 -14
  11. data/app/helpers/live_cable_helper.rb +28 -39
  12. data/config/importmap.rb +9 -3
  13. data/lib/generators/live_cable/component/component_generator.rb +58 -0
  14. data/lib/generators/live_cable/component/templates/component.rb.tt +29 -0
  15. data/lib/generators/live_cable/component/templates/view.html.live.erb.tt +2 -0
  16. data/lib/live_cable/component/broadcasting.rb +30 -0
  17. data/lib/live_cable/component/identification.rb +31 -0
  18. data/lib/live_cable/component/lifecycle.rb +67 -0
  19. data/lib/live_cable/component/method_dependency_tracking.rb +22 -0
  20. data/lib/live_cable/component/reactive_variables.rb +125 -0
  21. data/lib/live_cable/component/rendering.rb +177 -0
  22. data/lib/live_cable/component/streaming.rb +43 -0
  23. data/lib/live_cable/component.rb +21 -236
  24. data/lib/live_cable/configuration.rb +29 -0
  25. data/lib/live_cable/connection/broadcasting.rb +33 -0
  26. data/lib/live_cable/connection/channel_management.rb +13 -0
  27. data/lib/live_cable/connection/component_management.rb +38 -0
  28. data/lib/live_cable/connection/error_handling.rb +40 -0
  29. data/lib/live_cable/connection/messaging.rb +84 -0
  30. data/lib/live_cable/connection/state_management.rb +56 -0
  31. data/lib/live_cable/connection.rb +11 -180
  32. data/lib/live_cable/container.rb +25 -0
  33. data/lib/live_cable/delegation/array.rb +1 -0
  34. data/lib/live_cable/delegator.rb +0 -7
  35. data/lib/live_cable/engine.rb +15 -3
  36. data/lib/live_cable/observer.rb +5 -1
  37. data/lib/live_cable/observer_tracking.rb +20 -0
  38. data/lib/live_cable/render_context.rb +55 -8
  39. data/lib/live_cable/rendering/compiler.rb +80 -0
  40. data/lib/live_cable/rendering/dependency_visitor.rb +100 -0
  41. data/lib/live_cable/rendering/handler.rb +19 -0
  42. data/lib/live_cable/rendering/method_analyzer.rb +94 -0
  43. data/lib/live_cable/rendering/method_collector.rb +51 -0
  44. data/lib/live_cable/rendering/method_dependency_visitor.rb +51 -0
  45. data/lib/live_cable/rendering/partial.rb +93 -0
  46. data/lib/live_cable/rendering/partial_renderer.rb +145 -0
  47. data/lib/live_cable/rendering/render_result.rb +38 -0
  48. data/lib/live_cable/rendering/renderer.rb +150 -0
  49. data/lib/live_cable/version.rb +5 -0
  50. data/lib/live_cable.rb +15 -15
  51. metadata +124 -4
@@ -1,18 +1,17 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import SubscriptionManager from "live_cable_subscriptions"
2
+ import SubscriptionManager from "@isometriks/live_cable/subscriptions"
3
3
 
4
4
  export default class extends Controller {
5
5
  static values = {
6
6
  defaults: Object,
7
7
  status: String,
8
8
  component: String,
9
- liveId: String,
9
+ actions: Array,
10
+ id: String,
10
11
  }
11
12
 
12
13
  #subscription
13
- #formDebounce
14
- #reactiveDebounce
15
- #reactiveDebouncedMessage
14
+ #debounces = new Map()
16
15
 
17
16
  #callActionCallback = (event) => {
18
17
  event.stopPropagation()
@@ -26,24 +25,31 @@ export default class extends Controller {
26
25
  this.element.addEventListener("call", this.#callActionCallback)
27
26
 
28
27
  this.#subscription = SubscriptionManager.subscribe(
29
- this.liveIdValue,
28
+ this.idValue,
30
29
  this.componentValue,
31
30
  this.defaultsValue,
32
31
  this
33
32
  )
33
+
34
+ // Create callbacks for each action or form
35
+ this.actionsValue.forEach((action) => {
36
+ this[`action_$${action}`] = ({ params }) => {
37
+ this.sendCall(action, this.#convertKeysToSnakeCase(params))
38
+ }
39
+
40
+ this[`form_$${action}`] = (event) => {
41
+ this.#form(action, event)
42
+ }
43
+ })
34
44
  }
35
45
 
36
46
  disconnect() {
37
47
  this.element.removeEventListener("call", this.#callActionCallback)
38
48
  }
39
49
 
40
- call({ params }) {
41
- this.sendCall(params.action, params)
42
- }
43
-
44
50
  sendCall(action, params = {}) {
45
51
  this.#subscription.send(
46
- this.#unshiftDebounced(this.#callMessage(params, action))
52
+ this.#flushDebounced(this.#callMessage(params, action))
47
53
  )
48
54
  }
49
55
 
@@ -54,13 +60,31 @@ export default class extends Controller {
54
60
  }
55
61
  }
56
62
 
57
- reactive({ target }) {
58
- this.sendReactive(target)
63
+ #convertKeysToSnakeCase(params) {
64
+ return Object.fromEntries(
65
+ Object.entries(params).map(([key, value]) => [
66
+ key.replace(/([A-Z])/g, '_$1').toLowerCase(),
67
+ value
68
+ ])
69
+ )
70
+ }
71
+
72
+ reactive({ target, params }) {
73
+ const debounce = params?.debounce
74
+
75
+ if (debounce) {
76
+ this.#setDebounce(target, debounce, () => {
77
+ this.sendReactive(target)
78
+ }, this.#reactiveMessage(target))
79
+ } else {
80
+ this.sendReactive(target)
81
+ }
59
82
  }
60
83
 
61
84
  sendReactive(target) {
85
+ this.#clearDebounce(target)
62
86
  this.#subscription.send(
63
- this.#unshiftDebounced(this.#reactiveMessage(target))
87
+ this.#flushDebounced(this.#reactiveMessage(target))
64
88
  )
65
89
  }
66
90
 
@@ -72,49 +96,60 @@ export default class extends Controller {
72
96
  }
73
97
  }
74
98
 
75
- #unshiftDebounced(message) {
76
- const messages = [message]
77
-
78
- if (this.#reactiveDebouncedMessage) {
79
- messages.unshift(this.#reactiveDebouncedMessage)
80
- this.#reactiveDebouncedMessage = null
81
- }
82
-
83
- return { messages, _csrf_token: this.#csrfToken }
84
- }
99
+ #form(action, { currentTarget, params }) {
100
+ const debounce = params.debounce
85
101
 
86
- reactiveDebounce(event) {
87
- clearTimeout(this.#reactiveDebounce)
88
- this.#reactiveDebouncedMessage = this.#reactiveMessage(event.target)
102
+ if (debounce) {
103
+ const formData = new FormData(currentTarget)
104
+ const formParams = new URLSearchParams(formData).toString()
89
105
 
90
- this.#reactiveDebounce = setTimeout(() => {
91
- this.#reactiveDebouncedMessage = null
92
- this.reactive(event)
93
- }, event.params.debounce || 200)
94
- }
95
-
96
- form({ currentTarget, params: { action } }) {
97
- this.sendForm(action, currentTarget)
106
+ this.#setDebounce(currentTarget, debounce, () => {
107
+ this.sendForm(action, currentTarget)
108
+ }, this.#callMessage(formParams, action))
109
+ } else {
110
+ this.sendForm(action, currentTarget)
111
+ }
98
112
  }
99
113
 
100
114
  sendForm(action, formEl) {
101
- // Clear reactive debounce so it doesn't fire after form
102
- clearTimeout(this.#reactiveDebounce)
103
- clearTimeout(this.#formDebounce)
115
+ this.#clearDebounce(formEl)
104
116
 
105
117
  const formData = new FormData(formEl)
106
118
  const params = new URLSearchParams(formData).toString()
107
119
 
108
120
  this.#subscription.send(
109
- this.#unshiftDebounced(this.#callMessage(params, action))
121
+ this.#flushDebounced(this.#callMessage(params, action))
110
122
  )
111
123
  }
112
124
 
113
- formDebounce(event) {
114
- clearTimeout(this.#formDebounce)
115
- this.#formDebounce = setTimeout(() => {
116
- this.form(event)
117
- }, event.params.debounce || 200)
125
+ #setDebounce(source, delay, callback, message) {
126
+ // Clear existing debounce for this source
127
+ this.#clearDebounce(source)
128
+
129
+ // Set new debounce
130
+ const timeout = setTimeout(callback, delay)
131
+ this.#debounces.set(source, { timeout, message })
132
+ }
133
+
134
+ #clearDebounce(source) {
135
+ const debounce = this.#debounces.get(source)
136
+ if (debounce) {
137
+ clearTimeout(debounce.timeout)
138
+ this.#debounces.delete(source)
139
+ }
140
+ }
141
+
142
+ #flushDebounced(message) {
143
+ const messages = [message]
144
+
145
+ // Add all pending debounced messages to be sent immediately
146
+ for (const [source, { timeout, message: debouncedMessage }] of this.#debounces) {
147
+ clearTimeout(timeout)
148
+ messages.unshift(debouncedMessage)
149
+ }
150
+ this.#debounces.clear()
151
+
152
+ return { messages, _csrf_token: this.#csrfToken }
118
153
  }
119
154
 
120
155
  get #csrfToken() {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * DOM transformation module for LiveCable
3
+ *
4
+ * Converts live-* HTML attributes into Stimulus data-* attributes at runtime.
5
+ * This allows for a cleaner HTML syntax while maintaining full Stimulus compatibility.
6
+ */
7
+ class DOM {
8
+ // All live-* attributes that trigger DOM processing
9
+ static LIVE_ATTRIBUTES = [
10
+ 'live-id',
11
+ 'live-component',
12
+ 'live-defaults',
13
+ 'live-actions',
14
+ 'live-form',
15
+ 'live-action',
16
+ 'live-reactive'
17
+ ]
18
+
19
+ /**
20
+ * Transforms all live-* attributes on an element and its descendants
21
+ * @param {HTMLElement} element - The root element to process
22
+ */
23
+ mutate(element) {
24
+ if (!element || !element.querySelectorAll) {
25
+ return
26
+ }
27
+
28
+ // Build selector for all live-* attributes we care about
29
+ const selector = DOM.LIVE_ATTRIBUTES.map(attr => `[${attr}]`).join(', ')
30
+
31
+ // Get all elements with live-* attributes (including the root element if it matches)
32
+ const liveElements = []
33
+ if (element.matches && element.matches(selector)) {
34
+ liveElements.push(element)
35
+ }
36
+ liveElements.push(...element.querySelectorAll(selector))
37
+
38
+ liveElements.forEach(el => {
39
+ // Process component metadata attributes first
40
+ this.#processMetadataAttributes(el)
41
+
42
+ // Process interactive attributes (actions, forms, reactive)
43
+ this.#processInteractiveAttributes(el)
44
+
45
+ // Process live-id last so all other attributes are converted before
46
+ // the Stimulus controller is attached
47
+ if (el.hasAttribute('live-id')) {
48
+ this.#replaceLiveId(el)
49
+ }
50
+ })
51
+ }
52
+
53
+ #processMetadataAttributes(element) {
54
+ if (element.hasAttribute('live-component')) {
55
+ this.#replaceAttribute(element, 'live-component', 'data-live-component-value')
56
+ }
57
+ if (element.hasAttribute('live-defaults')) {
58
+ this.#replaceAttribute(element, 'live-defaults', 'data-live-defaults-value')
59
+ }
60
+ if (element.hasAttribute('live-actions')) {
61
+ this.#replaceAttribute(element, 'live-actions', 'data-live-actions-value')
62
+ }
63
+ }
64
+
65
+ #processInteractiveAttributes(element) {
66
+ // Note: convertValues and convertDebounce are called for each type as needed
67
+ if (element.hasAttribute('live-form')) {
68
+ this.#convertValues(element)
69
+ this.#convertDebounce(element)
70
+ this.#addActions(element, 'form', 'live-form', ':prevent')
71
+ }
72
+
73
+ if (element.hasAttribute('live-action')) {
74
+ this.#convertValues(element)
75
+ this.#convertDebounce(element)
76
+ this.#addActions(element, 'action', 'live-action')
77
+ }
78
+
79
+ if (element.hasAttribute('live-reactive')) {
80
+ this.#convertDebounce(element)
81
+ this.#convertReactive(element)
82
+ }
83
+ }
84
+
85
+ #replaceAttribute(element, oldAttr, newAttr) {
86
+ const value = element.getAttribute(oldAttr)
87
+ if (value !== null) {
88
+ element.removeAttribute(oldAttr)
89
+ element.setAttribute(newAttr, value)
90
+ }
91
+ }
92
+
93
+ #addActions(element, type, attribute, eventModifier = '') {
94
+ const attributeValue = element.getAttribute(attribute)
95
+ if (!attributeValue) return
96
+
97
+ const actions = attributeValue.trim().split(/\s+/).map(actionString => {
98
+ const parts = actionString.split(/->/)
99
+ const [event, action] = parts.length === 2 ? parts : [null, parts[0]]
100
+ const stimulusEvent = event ? `${event}->` : ''
101
+
102
+ return `${stimulusEvent}live#${type}_$${action}${eventModifier}`
103
+ })
104
+
105
+ element.removeAttribute(attribute)
106
+ this.#appendToAttribute(element, 'data-action', actions.join(' '))
107
+ }
108
+
109
+ #convertValues(element) {
110
+ // Get all attributes that start with 'live-value-'
111
+ Array.from(element.attributes).forEach(attr => {
112
+ if (attr.name.startsWith('live-value-')) {
113
+ const paramName = attr.name.substring('live-value-'.length)
114
+ element.removeAttribute(attr.name)
115
+ element.setAttribute(`data-live-${paramName}-param`, attr.value)
116
+ }
117
+ })
118
+ }
119
+
120
+ #convertDebounce(element) {
121
+ if (element.hasAttribute('live-debounce')) {
122
+ const value = element.getAttribute('live-debounce')
123
+ element.removeAttribute('live-debounce')
124
+ element.setAttribute('data-live-debounce-param', value)
125
+ }
126
+ }
127
+
128
+ #convertReactive(element) {
129
+ const value = element.getAttribute('live-reactive')
130
+ element.removeAttribute('live-reactive')
131
+
132
+ if (!value || value.trim() === '') {
133
+ // No events specified, use default Stimulus event
134
+ this.#appendToAttribute(element, 'data-action', 'live#reactive')
135
+ } else {
136
+ // Multiple events specified, convert each to event->live#reactive
137
+ const actions = value.trim().split(/\s+/).map(event => `${event}->live#reactive`)
138
+ this.#appendToAttribute(element, 'data-action', actions.join(' '))
139
+ }
140
+ }
141
+
142
+ #replaceLiveId(element) {
143
+ this.#replaceAttribute(element, 'live-id', 'data-live-id-value')
144
+ this.#appendToAttribute(element, 'data-controller', 'live')
145
+ }
146
+
147
+ #appendToAttribute(element, attributeName, value) {
148
+ const existing = element.getAttribute(attributeName)
149
+
150
+ if (existing) {
151
+ const values = existing.split(/\s+/)
152
+ if (!values.includes(value)) {
153
+ element.setAttribute(attributeName, `${existing} ${value}`)
154
+ }
155
+ } else {
156
+ element.setAttribute(attributeName, value)
157
+ }
158
+ }
159
+ }
160
+
161
+ export default new DOM()
@@ -0,0 +1,50 @@
1
+ import LiveObserver from '@isometriks/live_cable/observer'
2
+ import SubscriptionManager from '@isometriks/live_cable/subscriptions'
3
+ import DOM from '@isometriks/live_cable/dom'
4
+
5
+ const observer = new LiveObserver()
6
+ observer.start()
7
+
8
+ document.addEventListener('turbo:before-render', (event) => {
9
+ SubscriptionManager.prune(event.detail.newBody)
10
+ })
11
+
12
+ document.addEventListener('turbo:load', () => {
13
+ DOM.mutate(document.documentElement)
14
+ updateCacheControl()
15
+ })
16
+
17
+ /**
18
+ * Ensure pages with live components are never stored in Turbo's page cache.
19
+ *
20
+ * Turbo's back/forward cache restores a snapshot of the page taken at
21
+ * navigate time. A restored snapshot would reconnect Stimulus controllers
22
+ * to subscriptions that were already closed server-side, causing a cold
23
+ * re-render that may fail. Preventing caching forces a fresh server fetch
24
+ * on back/forward, so components are always pre-rendered before their
25
+ * ActionCable subscription connects.
26
+ *
27
+ * If the developer has already set a turbo-cache-control meta tag (e.g.
28
+ * "no-store"), that value is left untouched so they can override this
29
+ * behaviour per-page.
30
+ */
31
+ function updateCacheControl() {
32
+ // A meta tag without our marker was placed by the developer — leave it alone
33
+ const devMeta = document.querySelector('meta[name="turbo-cache-control"]:not([data-live-cable])')
34
+ if (devMeta) return
35
+
36
+ const ourMeta = document.querySelector('meta[name="turbo-cache-control"][data-live-cable]')
37
+ const hasLiveComponents = !!document.querySelector('[data-controller~="live"]')
38
+
39
+ if (hasLiveComponents && !ourMeta) {
40
+ const meta = document.createElement('meta')
41
+ meta.name = 'turbo-cache-control'
42
+ meta.content = 'no-cache'
43
+ meta.dataset.liveCable = ''
44
+ document.head.appendChild(meta)
45
+ } else if (!hasLiveComponents && ourMeta) {
46
+ ourMeta.remove()
47
+ }
48
+ }
49
+
50
+ export default observer
@@ -1,4 +1,4 @@
1
- import LiveController from "live_cable_controller"
1
+ import LiveController from "@isometriks/live_cable/controller"
2
2
 
3
3
  export default function LiveCableBlessing(constructor) {
4
4
  Object.assign(constructor.prototype, {
@@ -0,0 +1,74 @@
1
+ import DOM from "@isometriks/live_cable/dom"
2
+
3
+ /**
4
+ * LiveCable DOM Observer
5
+ *
6
+ * Observes the DOM for elements with the `live-id` attribute
7
+ */
8
+ export default class LiveObserver {
9
+ #observer
10
+
11
+ /**
12
+ * Start observing the DOM for live components
13
+ */
14
+ start() {
15
+ if (this.#observer) {
16
+ return
17
+ }
18
+
19
+ this.#observer = new MutationObserver((mutations) => {
20
+ mutations.forEach((mutation) => {
21
+ // Handle added nodes
22
+ mutation.addedNodes.forEach((node) => {
23
+ if (node.nodeType === Node.ELEMENT_NODE) {
24
+ this.checkElement(node)
25
+ }
26
+ })
27
+
28
+ // Handle attribute changes
29
+ if (mutation.type === 'attributes' && mutation.attributeName === 'live-id') {
30
+ const element = mutation.target
31
+ if (element.hasAttribute('live-id')) {
32
+ DOM.mutate(element)
33
+ }
34
+ }
35
+ })
36
+ })
37
+
38
+ this.#observer.observe(document.documentElement, {
39
+ childList: true,
40
+ subtree: true,
41
+ attributes: true,
42
+ attributeFilter: ['live-id'],
43
+ })
44
+
45
+ // Check the DOM as well when we start
46
+ this.checkElement(document.documentElement)
47
+ }
48
+
49
+ /**
50
+ * Stop observing the DOM
51
+ */
52
+ stop() {
53
+ if (this.#observer) {
54
+ this.#observer.disconnect()
55
+ this.#observer = null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check if an element or its descendants have live-id attribute
61
+ */
62
+ checkElement(element) {
63
+ if (element.hasAttribute && element.hasAttribute('live-id')) {
64
+ DOM.mutate(element)
65
+ }
66
+
67
+ if (element.querySelectorAll) {
68
+ const liveElements = element.querySelectorAll('[live-id]')
69
+ liveElements.forEach((liveElement) => {
70
+ DOM.mutate(liveElement)
71
+ })
72
+ }
73
+ }
74
+ }