funicular 0.0.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
data/mrblib/cable.rb
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module Cable
|
|
3
|
+
# localStorage key for persisting pending commands
|
|
4
|
+
STORAGE_KEY = "funicular_cable_pending"
|
|
5
|
+
|
|
6
|
+
# Create a new Consumer instance connected to the specified URL
|
|
7
|
+
# @param url [String] WebSocket URL (e.g., "ws://localhost:3000/cable")
|
|
8
|
+
# @return [Consumer]
|
|
9
|
+
def self.create_consumer(url)
|
|
10
|
+
Consumer.new(url)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Consumer manages the WebSocket connection and subscriptions
|
|
14
|
+
class Consumer
|
|
15
|
+
attr_reader :url, :subscriptions
|
|
16
|
+
|
|
17
|
+
def initialize(url)
|
|
18
|
+
@url = url
|
|
19
|
+
@subscriptions = Subscriptions.new(self)
|
|
20
|
+
@websocket = nil
|
|
21
|
+
@connected = false
|
|
22
|
+
@reconnect_attempts = 0
|
|
23
|
+
@reconnect_timer = nil
|
|
24
|
+
@pending_commands = load_pending_from_storage
|
|
25
|
+
@suspend_timer = nil
|
|
26
|
+
@suspended = false
|
|
27
|
+
@visibility_callback_id = nil
|
|
28
|
+
@beforeunload_callback_id = nil
|
|
29
|
+
connect
|
|
30
|
+
setup_visibility_handler
|
|
31
|
+
setup_beforeunload_handler
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Establish WebSocket connection
|
|
35
|
+
def connect
|
|
36
|
+
# puts "[Cable] Creating WebSocket connection to #{@url}"
|
|
37
|
+
@websocket = JS::WebSocket.new(@url)
|
|
38
|
+
# puts "[Cable] WebSocket object created, setting up handlers"
|
|
39
|
+
|
|
40
|
+
@websocket.onopen do |event|
|
|
41
|
+
was_reconnect = @reconnect_attempts > 0
|
|
42
|
+
@connected = true
|
|
43
|
+
@reconnect_attempts = 0
|
|
44
|
+
# puts "[Cable] Connected to #{@url}"
|
|
45
|
+
# Delay flush to ensure connection is stable
|
|
46
|
+
JS.global.setTimeout(100) do
|
|
47
|
+
next unless @connected
|
|
48
|
+
# On reconnect the server has forgotten our subscriptions; re-issue
|
|
49
|
+
# subscribe commands before flushing any pending perform/unsubscribe
|
|
50
|
+
# so the server knows about them when those messages arrive.
|
|
51
|
+
@subscriptions.resubscribe_all if was_reconnect
|
|
52
|
+
flush_pending_commands
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@websocket.onmessage do |event|
|
|
57
|
+
begin
|
|
58
|
+
# event is a JS::Object wrapping MessageEvent
|
|
59
|
+
# Access data property and convert to Ruby string
|
|
60
|
+
data_obj = event[:data]
|
|
61
|
+
data_str = data_obj.to_s # Always convert JS::Object to Ruby String
|
|
62
|
+
handle_message(data_str)
|
|
63
|
+
rescue => e
|
|
64
|
+
puts "[Cable] Error in onmessage: #{e.class}: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@websocket.onerror do |event|
|
|
69
|
+
# puts "[Cable] Error occurred"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@websocket.onclose do |event|
|
|
73
|
+
@connected = false
|
|
74
|
+
# puts "[Cable] Disconnected"
|
|
75
|
+
schedule_reconnect
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Send a command to the server
|
|
80
|
+
# @param command [Hash] Command data
|
|
81
|
+
def send_command(command)
|
|
82
|
+
if @connected && @websocket&.open?
|
|
83
|
+
json = JSON.generate(command)
|
|
84
|
+
# puts "[Cable] Sending command: #{json}"
|
|
85
|
+
@websocket.send(json)
|
|
86
|
+
else
|
|
87
|
+
# puts "[Cable] Queuing command (not connected): #{command.inspect}"
|
|
88
|
+
@pending_commands << command
|
|
89
|
+
save_pending_to_storage
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Disconnect and clean up
|
|
94
|
+
def disconnect
|
|
95
|
+
@websocket&.close if @websocket
|
|
96
|
+
@websocket = nil
|
|
97
|
+
@connected = false
|
|
98
|
+
cancel_suspend
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Full cleanup including event listeners (call when Consumer is no longer needed)
|
|
102
|
+
def cleanup
|
|
103
|
+
disconnect
|
|
104
|
+
cleanup_event_listeners
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Remove global event listeners
|
|
108
|
+
def cleanup_event_listeners
|
|
109
|
+
if @visibility_callback_id
|
|
110
|
+
JS::Object.removeEventListener(@visibility_callback_id)
|
|
111
|
+
@visibility_callback_id = nil
|
|
112
|
+
end
|
|
113
|
+
if @beforeunload_callback_id
|
|
114
|
+
JS::Object.removeEventListener(@beforeunload_callback_id)
|
|
115
|
+
@beforeunload_callback_id = nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Handle incoming message
|
|
122
|
+
def handle_message(data)
|
|
123
|
+
message = JSON.parse(data)
|
|
124
|
+
type = message["type"]
|
|
125
|
+
identifier = message["identifier"]
|
|
126
|
+
|
|
127
|
+
case type
|
|
128
|
+
when "ping"
|
|
129
|
+
# Heartbeat - no action needed (silent)
|
|
130
|
+
when "welcome"
|
|
131
|
+
# puts "[Cable] Welcome message received"
|
|
132
|
+
when "confirm_subscription"
|
|
133
|
+
@subscriptions.notify_subscription_confirmed(identifier)
|
|
134
|
+
when "reject_subscription"
|
|
135
|
+
@subscriptions.notify_subscription_rejected(identifier)
|
|
136
|
+
else
|
|
137
|
+
# Regular message
|
|
138
|
+
@subscriptions.notify_message(identifier, message["message"])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Flush pending commands after reconnection
|
|
143
|
+
def flush_pending_commands
|
|
144
|
+
return if @pending_commands.empty?
|
|
145
|
+
|
|
146
|
+
# puts "[Cable] Flushing #{@pending_commands.size} pending commands"
|
|
147
|
+
@pending_commands.each do |command|
|
|
148
|
+
json = JSON.generate(command)
|
|
149
|
+
@websocket.send(json)
|
|
150
|
+
end
|
|
151
|
+
@pending_commands.clear
|
|
152
|
+
clear_pending_storage
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Schedule reconnection with exponential backoff
|
|
156
|
+
def schedule_reconnect
|
|
157
|
+
begin
|
|
158
|
+
return if @suspended
|
|
159
|
+
delay = calculate_backoff_delay
|
|
160
|
+
# puts "[Cable] Reconnecting in #{delay} seconds (attempt #{@reconnect_attempts + 1})"
|
|
161
|
+
sleep delay
|
|
162
|
+
# puts "[Cable] Sleep completed, attempting reconnection..."
|
|
163
|
+
return if @suspended
|
|
164
|
+
@reconnect_attempts += 1
|
|
165
|
+
connect
|
|
166
|
+
rescue => e
|
|
167
|
+
puts "[Cable] Error in schedule_reconnect: #{e.class}: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Calculate exponential backoff delay (1s, 2s, 4s, 8s, 16s, 32s, max 60s)
|
|
172
|
+
def calculate_backoff_delay
|
|
173
|
+
base_delay = 1
|
|
174
|
+
max_delay = 60
|
|
175
|
+
exp = 2 ** @reconnect_attempts #: Integer
|
|
176
|
+
delay = base_delay * exp
|
|
177
|
+
delay < max_delay ? delay : max_delay
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Setup Page Visibility API handler
|
|
181
|
+
def setup_visibility_handler
|
|
182
|
+
@visibility_callback_id = JS.document.addEventListener("visibilitychange") do
|
|
183
|
+
if JS.document[:hidden]
|
|
184
|
+
schedule_suspend
|
|
185
|
+
else
|
|
186
|
+
cancel_suspend
|
|
187
|
+
ensure_connected
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Schedule suspension after 30 seconds in background
|
|
193
|
+
def schedule_suspend
|
|
194
|
+
return if @suspended || @suspend_timer
|
|
195
|
+
# puts "[Cable] Page hidden, scheduling suspension in 30 seconds"
|
|
196
|
+
@suspend_timer = JS.global.setTimeout(30000) do
|
|
197
|
+
suspend_connection
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Cancel scheduled suspension
|
|
202
|
+
def cancel_suspend
|
|
203
|
+
if @suspend_timer
|
|
204
|
+
# puts "[Cable] Page visible, canceling suspension"
|
|
205
|
+
if JS.global.clearTimeout(@suspend_timer)
|
|
206
|
+
@suspend_timer = nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Suspend connection and subscriptions
|
|
212
|
+
def suspend_connection
|
|
213
|
+
return if @suspended
|
|
214
|
+
return unless @suspend_timer
|
|
215
|
+
@suspend_timer = nil
|
|
216
|
+
@suspended = true
|
|
217
|
+
# puts "[Cable] Suspending connection (page in background)"
|
|
218
|
+
disconnect
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Ensure connection is established
|
|
222
|
+
def ensure_connected
|
|
223
|
+
if @suspended
|
|
224
|
+
@suspended = false
|
|
225
|
+
# puts "[Cable] Resuming connection (page visible)"
|
|
226
|
+
connect
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Setup beforeunload handler to persist pending commands
|
|
231
|
+
def setup_beforeunload_handler
|
|
232
|
+
@beforeunload_callback_id = JS.global.addEventListener("beforeunload") do
|
|
233
|
+
save_pending_to_storage
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Save pending commands to localStorage
|
|
238
|
+
def save_pending_to_storage
|
|
239
|
+
return if @pending_commands.empty?
|
|
240
|
+
begin
|
|
241
|
+
json = JSON.generate(@pending_commands)
|
|
242
|
+
storage = JS.global[:localStorage]
|
|
243
|
+
storage.setItem(STORAGE_KEY, json) if storage.is_a?(JS::Object)
|
|
244
|
+
rescue => e
|
|
245
|
+
puts "[Cable] Error saving to localStorage: #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Load pending commands from localStorage
|
|
250
|
+
def load_pending_from_storage
|
|
251
|
+
begin
|
|
252
|
+
storage = JS.global[:localStorage]
|
|
253
|
+
return [] unless storage.is_a?(JS::Object)
|
|
254
|
+
stored = storage.getItem(STORAGE_KEY)
|
|
255
|
+
stored.is_a?(String) ? JSON.parse(stored) : []
|
|
256
|
+
rescue => e
|
|
257
|
+
puts "[Cable] Error loading from localStorage: #{e.message}"
|
|
258
|
+
[]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Clear pending commands from localStorage
|
|
263
|
+
def clear_pending_storage
|
|
264
|
+
begin
|
|
265
|
+
storage = JS.global[:localStorage]
|
|
266
|
+
storage.removeItem(STORAGE_KEY) if storage.is_a?(JS::Object)
|
|
267
|
+
rescue => e
|
|
268
|
+
puts "[Cable] Error clearing localStorage: #{e.message}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Subscriptions manages a collection of Subscription instances
|
|
274
|
+
class Subscriptions
|
|
275
|
+
def initialize(consumer)
|
|
276
|
+
@consumer = consumer
|
|
277
|
+
@subscriptions = {}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Create a new subscription
|
|
281
|
+
# @param params [Hash] Channel parameters (e.g., {channel: "ChatChannel", room: "lobby"})
|
|
282
|
+
# @yield [message] Block to handle incoming messages
|
|
283
|
+
# @return [Subscription]
|
|
284
|
+
def create(params, &block)
|
|
285
|
+
# puts "[Cable] Creating subscription with params: #{params.inspect}"
|
|
286
|
+
identifier = JSON.generate(params)
|
|
287
|
+
# puts "[Cable] Generated identifier: #{identifier}"
|
|
288
|
+
# puts "[Cable] Identifier length: #{identifier.length}"
|
|
289
|
+
subscription = Subscription.new(@consumer, identifier, params, &block)
|
|
290
|
+
@subscriptions[identifier] = subscription
|
|
291
|
+
subscription.subscribe
|
|
292
|
+
subscription
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Find a subscription by identifier
|
|
296
|
+
def find(identifier)
|
|
297
|
+
@subscriptions[identifier]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Remove a subscription
|
|
301
|
+
def remove(subscription)
|
|
302
|
+
@subscriptions.delete(subscription.identifier)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Notify subscription confirmed
|
|
306
|
+
def notify_subscription_confirmed(identifier)
|
|
307
|
+
subscription = @subscriptions[identifier]
|
|
308
|
+
return unless subscription
|
|
309
|
+
subscription.notify_connected
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Notify subscription rejected
|
|
313
|
+
def notify_subscription_rejected(identifier)
|
|
314
|
+
subscription = @subscriptions[identifier]
|
|
315
|
+
return unless subscription
|
|
316
|
+
subscription.notify_rejected
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Notify message received
|
|
320
|
+
def notify_message(identifier, message)
|
|
321
|
+
subscription = @subscriptions[identifier]
|
|
322
|
+
return unless subscription
|
|
323
|
+
subscription.notify_received(message)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Re-issue subscribe for every active subscription. Used after a
|
|
327
|
+
# WebSocket reconnect, where the server has dropped its subscription
|
|
328
|
+
# state but the client-side Subscription objects are still valid.
|
|
329
|
+
def resubscribe_all
|
|
330
|
+
@subscriptions.each_value do |subscription|
|
|
331
|
+
subscription.subscribe
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Subscription represents a subscription to a specific channel
|
|
337
|
+
class Subscription
|
|
338
|
+
attr_reader :consumer, :identifier, :params
|
|
339
|
+
|
|
340
|
+
def initialize(consumer, identifier, params, &block)
|
|
341
|
+
@consumer = consumer
|
|
342
|
+
@identifier = identifier
|
|
343
|
+
@params = params
|
|
344
|
+
@callbacks = {
|
|
345
|
+
received: block,
|
|
346
|
+
connected: nil,
|
|
347
|
+
disconnected: nil,
|
|
348
|
+
rejected: nil
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Subscribe to the channel
|
|
353
|
+
def subscribe
|
|
354
|
+
command = {
|
|
355
|
+
command: "subscribe",
|
|
356
|
+
identifier: @identifier
|
|
357
|
+
}
|
|
358
|
+
@consumer.send_command(command)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Unsubscribe from the channel
|
|
362
|
+
def unsubscribe
|
|
363
|
+
command = {
|
|
364
|
+
command: "unsubscribe",
|
|
365
|
+
identifier: @identifier
|
|
366
|
+
}
|
|
367
|
+
@consumer.send_command(command)
|
|
368
|
+
@consumer.subscriptions.remove(self)
|
|
369
|
+
# Clear callbacks to release references
|
|
370
|
+
@callbacks = {
|
|
371
|
+
received: nil,
|
|
372
|
+
connected: nil,
|
|
373
|
+
disconnected: nil,
|
|
374
|
+
rejected: nil
|
|
375
|
+
}
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Perform an action on the channel
|
|
379
|
+
# @param action [String] Action name
|
|
380
|
+
# @param data [Hash] Additional data
|
|
381
|
+
def perform(action, data = {})
|
|
382
|
+
idempotency_key = generate_idempotency_key
|
|
383
|
+
payload = data.merge(action: action, _idempotency_key: idempotency_key)
|
|
384
|
+
command = {
|
|
385
|
+
command: "message",
|
|
386
|
+
identifier: @identifier,
|
|
387
|
+
data: JSON.generate(payload)
|
|
388
|
+
}
|
|
389
|
+
@consumer.send_command(command)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Register connected callback
|
|
393
|
+
def on_connected(&block)
|
|
394
|
+
@callbacks[:connected] = block
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Register disconnected callback
|
|
398
|
+
def on_disconnected(&block)
|
|
399
|
+
@callbacks[:disconnected] = block
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Register rejected callback
|
|
403
|
+
def on_rejected(&block)
|
|
404
|
+
@callbacks[:rejected] = block
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Internal: notify subscription connected
|
|
408
|
+
def notify_connected
|
|
409
|
+
# puts "[Cable] Subscription confirmed: #{@identifier}"
|
|
410
|
+
@callbacks[:connected]&.call
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Internal: notify subscription rejected
|
|
414
|
+
def notify_rejected
|
|
415
|
+
# puts "[Cable] Subscription rejected: #{@identifier}"
|
|
416
|
+
@callbacks[:rejected]&.call
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Internal: notify message received
|
|
420
|
+
def notify_received(message)
|
|
421
|
+
@callbacks[:received]&.call(message)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
private
|
|
425
|
+
|
|
426
|
+
# Generate a unique idempotency key for message deduplication
|
|
427
|
+
def generate_idempotency_key
|
|
428
|
+
RNG.uuid
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|