funicular 0.0.1 → 0.1.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 +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -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/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -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 +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -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 +6404 -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/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -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 +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
data/mrbgem.rake
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
|
|
2
|
+
spec.license = 'MIT'
|
|
3
|
+
spec.author = 'HASUMI Hitoshi'
|
|
4
|
+
spec.summary = 'Browser application framework with VDOM for PicoRuby.wasm'
|
|
5
|
+
|
|
6
|
+
unless ENV['TEST_TASK']
|
|
7
|
+
spec.add_dependency 'picoruby-wasm'
|
|
8
|
+
end
|
|
9
|
+
spec.add_dependency 'picoruby-json'
|
|
10
|
+
spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
|
|
11
|
+
spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
|
|
12
|
+
spec.add_dependency 'mruby-array-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-array-ext"
|
|
13
|
+
spec.add_dependency 'mruby-string-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-string-ext"
|
|
14
|
+
spec.add_dependency 'mruby-metaprog', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-metaprog"
|
|
15
|
+
end
|
data/mrblib/cable.rb
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
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
|
+
@connected = true
|
|
42
|
+
@reconnect_attempts = 0
|
|
43
|
+
# puts "[Cable] Connected to #{@url}"
|
|
44
|
+
# Delay flush to ensure connection is stable
|
|
45
|
+
JS.global.setTimeout(100) do
|
|
46
|
+
flush_pending_commands if @connected
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@websocket.onmessage do |event|
|
|
51
|
+
begin
|
|
52
|
+
# event is a JS::Object wrapping MessageEvent
|
|
53
|
+
# Access data property and convert to Ruby string
|
|
54
|
+
data_obj = event[:data]
|
|
55
|
+
data_str = data_obj.to_s # Always convert JS::Object to Ruby String
|
|
56
|
+
handle_message(data_str)
|
|
57
|
+
rescue => e
|
|
58
|
+
puts "[Cable] Error in onmessage: #{e.class}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@websocket.onerror do |event|
|
|
63
|
+
# puts "[Cable] Error occurred"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@websocket.onclose do |event|
|
|
67
|
+
@connected = false
|
|
68
|
+
# puts "[Cable] Disconnected"
|
|
69
|
+
schedule_reconnect
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Send a command to the server
|
|
74
|
+
# @param command [Hash] Command data
|
|
75
|
+
def send_command(command)
|
|
76
|
+
if @connected && @websocket&.open?
|
|
77
|
+
json = JSON.generate(command)
|
|
78
|
+
# puts "[Cable] Sending command: #{json}"
|
|
79
|
+
@websocket.send(json)
|
|
80
|
+
else
|
|
81
|
+
# puts "[Cable] Queuing command (not connected): #{command.inspect}"
|
|
82
|
+
@pending_commands << command
|
|
83
|
+
save_pending_to_storage
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Disconnect and clean up
|
|
88
|
+
def disconnect
|
|
89
|
+
@websocket&.close if @websocket
|
|
90
|
+
@websocket = nil
|
|
91
|
+
@connected = false
|
|
92
|
+
cancel_suspend
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Full cleanup including event listeners (call when Consumer is no longer needed)
|
|
96
|
+
def cleanup
|
|
97
|
+
disconnect
|
|
98
|
+
cleanup_event_listeners
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Remove global event listeners
|
|
102
|
+
def cleanup_event_listeners
|
|
103
|
+
if @visibility_callback_id
|
|
104
|
+
JS::Object.removeEventListener(@visibility_callback_id)
|
|
105
|
+
@visibility_callback_id = nil
|
|
106
|
+
end
|
|
107
|
+
if @beforeunload_callback_id
|
|
108
|
+
JS::Object.removeEventListener(@beforeunload_callback_id)
|
|
109
|
+
@beforeunload_callback_id = nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Handle incoming message
|
|
116
|
+
def handle_message(data)
|
|
117
|
+
message = JSON.parse(data)
|
|
118
|
+
type = message["type"]
|
|
119
|
+
identifier = message["identifier"]
|
|
120
|
+
|
|
121
|
+
case type
|
|
122
|
+
when "ping"
|
|
123
|
+
# Heartbeat - no action needed (silent)
|
|
124
|
+
when "welcome"
|
|
125
|
+
# puts "[Cable] Welcome message received"
|
|
126
|
+
when "confirm_subscription"
|
|
127
|
+
@subscriptions.notify_subscription_confirmed(identifier)
|
|
128
|
+
when "reject_subscription"
|
|
129
|
+
@subscriptions.notify_subscription_rejected(identifier)
|
|
130
|
+
else
|
|
131
|
+
# Regular message
|
|
132
|
+
@subscriptions.notify_message(identifier, message["message"])
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Flush pending commands after reconnection
|
|
137
|
+
def flush_pending_commands
|
|
138
|
+
return if @pending_commands.empty?
|
|
139
|
+
|
|
140
|
+
# puts "[Cable] Flushing #{@pending_commands.size} pending commands"
|
|
141
|
+
@pending_commands.each do |command|
|
|
142
|
+
json = JSON.generate(command)
|
|
143
|
+
@websocket.send(json)
|
|
144
|
+
end
|
|
145
|
+
@pending_commands.clear
|
|
146
|
+
clear_pending_storage
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Schedule reconnection with exponential backoff
|
|
150
|
+
def schedule_reconnect
|
|
151
|
+
begin
|
|
152
|
+
return if @suspended
|
|
153
|
+
delay = calculate_backoff_delay
|
|
154
|
+
# puts "[Cable] Reconnecting in #{delay} seconds (attempt #{@reconnect_attempts + 1})"
|
|
155
|
+
sleep delay
|
|
156
|
+
# puts "[Cable] Sleep completed, attempting reconnection..."
|
|
157
|
+
return if @suspended
|
|
158
|
+
@reconnect_attempts += 1
|
|
159
|
+
connect
|
|
160
|
+
rescue => e
|
|
161
|
+
puts "[Cable] Error in schedule_reconnect: #{e.class}: #{e.message}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Calculate exponential backoff delay (1s, 2s, 4s, 8s, 16s, 32s, max 60s)
|
|
166
|
+
def calculate_backoff_delay
|
|
167
|
+
base_delay = 1
|
|
168
|
+
max_delay = 60
|
|
169
|
+
exp = 2 ** @reconnect_attempts #: Integer
|
|
170
|
+
delay = base_delay * exp
|
|
171
|
+
delay < max_delay ? delay : max_delay
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Setup Page Visibility API handler
|
|
175
|
+
def setup_visibility_handler
|
|
176
|
+
@visibility_callback_id = JS.document.addEventListener("visibilitychange") do
|
|
177
|
+
if JS.document[:hidden]
|
|
178
|
+
schedule_suspend
|
|
179
|
+
else
|
|
180
|
+
cancel_suspend
|
|
181
|
+
ensure_connected
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Schedule suspension after 30 seconds in background
|
|
187
|
+
def schedule_suspend
|
|
188
|
+
return if @suspended || @suspend_timer
|
|
189
|
+
# puts "[Cable] Page hidden, scheduling suspension in 30 seconds"
|
|
190
|
+
@suspend_timer = JS.global.setTimeout(30000) do
|
|
191
|
+
suspend_connection
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Cancel scheduled suspension
|
|
196
|
+
def cancel_suspend
|
|
197
|
+
if @suspend_timer
|
|
198
|
+
# puts "[Cable] Page visible, canceling suspension"
|
|
199
|
+
if JS.global.clearTimeout(@suspend_timer)
|
|
200
|
+
@suspend_timer = nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Suspend connection and subscriptions
|
|
206
|
+
def suspend_connection
|
|
207
|
+
return if @suspended
|
|
208
|
+
return unless @suspend_timer
|
|
209
|
+
@suspend_timer = nil
|
|
210
|
+
@suspended = true
|
|
211
|
+
# puts "[Cable] Suspending connection (page in background)"
|
|
212
|
+
disconnect
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Ensure connection is established
|
|
216
|
+
def ensure_connected
|
|
217
|
+
if @suspended
|
|
218
|
+
@suspended = false
|
|
219
|
+
# puts "[Cable] Resuming connection (page visible)"
|
|
220
|
+
connect
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Setup beforeunload handler to persist pending commands
|
|
225
|
+
def setup_beforeunload_handler
|
|
226
|
+
@beforeunload_callback_id = JS.global.addEventListener("beforeunload") do
|
|
227
|
+
save_pending_to_storage
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Save pending commands to localStorage
|
|
232
|
+
def save_pending_to_storage
|
|
233
|
+
return if @pending_commands.empty?
|
|
234
|
+
begin
|
|
235
|
+
json = JSON.generate(@pending_commands)
|
|
236
|
+
JS.global[:localStorage]&.setItem(STORAGE_KEY, json)
|
|
237
|
+
rescue => e
|
|
238
|
+
puts "[Cable] Error saving to localStorage: #{e.message}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Load pending commands from localStorage
|
|
243
|
+
def load_pending_from_storage
|
|
244
|
+
begin
|
|
245
|
+
stored = JS.global[:localStorage]&.getItem(STORAGE_KEY)
|
|
246
|
+
if stored
|
|
247
|
+
JSON.parse(stored.to_s)
|
|
248
|
+
else
|
|
249
|
+
[]
|
|
250
|
+
end
|
|
251
|
+
rescue => e
|
|
252
|
+
puts "[Cable] Error loading from localStorage: #{e.message}"
|
|
253
|
+
[]
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Clear pending commands from localStorage
|
|
258
|
+
def clear_pending_storage
|
|
259
|
+
begin
|
|
260
|
+
JS.global[:localStorage]&.removeItem(STORAGE_KEY)
|
|
261
|
+
rescue => e
|
|
262
|
+
puts "[Cable] Error clearing localStorage: #{e.message}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Subscriptions manages a collection of Subscription instances
|
|
268
|
+
class Subscriptions
|
|
269
|
+
def initialize(consumer)
|
|
270
|
+
@consumer = consumer
|
|
271
|
+
@subscriptions = {}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Create a new subscription
|
|
275
|
+
# @param params [Hash] Channel parameters (e.g., {channel: "ChatChannel", room: "lobby"})
|
|
276
|
+
# @yield [message] Block to handle incoming messages
|
|
277
|
+
# @return [Subscription]
|
|
278
|
+
def create(params, &block)
|
|
279
|
+
# puts "[Cable] Creating subscription with params: #{params.inspect}"
|
|
280
|
+
identifier = JSON.generate(params)
|
|
281
|
+
# puts "[Cable] Generated identifier: #{identifier}"
|
|
282
|
+
# puts "[Cable] Identifier length: #{identifier.length}"
|
|
283
|
+
subscription = Subscription.new(@consumer, identifier, params, &block)
|
|
284
|
+
@subscriptions[identifier] = subscription
|
|
285
|
+
subscription.subscribe
|
|
286
|
+
subscription
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Find a subscription by identifier
|
|
290
|
+
def find(identifier)
|
|
291
|
+
@subscriptions[identifier]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Remove a subscription
|
|
295
|
+
def remove(subscription)
|
|
296
|
+
@subscriptions.delete(subscription.identifier)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Notify subscription confirmed
|
|
300
|
+
def notify_subscription_confirmed(identifier)
|
|
301
|
+
subscription = @subscriptions[identifier]
|
|
302
|
+
return unless subscription
|
|
303
|
+
subscription.notify_connected
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Notify subscription rejected
|
|
307
|
+
def notify_subscription_rejected(identifier)
|
|
308
|
+
subscription = @subscriptions[identifier]
|
|
309
|
+
return unless subscription
|
|
310
|
+
subscription.notify_rejected
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Notify message received
|
|
314
|
+
def notify_message(identifier, message)
|
|
315
|
+
subscription = @subscriptions[identifier]
|
|
316
|
+
return unless subscription
|
|
317
|
+
subscription.notify_received(message)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Subscription represents a subscription to a specific channel
|
|
322
|
+
class Subscription
|
|
323
|
+
attr_reader :consumer, :identifier, :params
|
|
324
|
+
|
|
325
|
+
def initialize(consumer, identifier, params, &block)
|
|
326
|
+
@consumer = consumer
|
|
327
|
+
@identifier = identifier
|
|
328
|
+
@params = params
|
|
329
|
+
@callbacks = {
|
|
330
|
+
received: block,
|
|
331
|
+
connected: nil,
|
|
332
|
+
disconnected: nil,
|
|
333
|
+
rejected: nil
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Subscribe to the channel
|
|
338
|
+
def subscribe
|
|
339
|
+
command = {
|
|
340
|
+
command: "subscribe",
|
|
341
|
+
identifier: @identifier
|
|
342
|
+
}
|
|
343
|
+
@consumer.send_command(command)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Unsubscribe from the channel
|
|
347
|
+
def unsubscribe
|
|
348
|
+
command = {
|
|
349
|
+
command: "unsubscribe",
|
|
350
|
+
identifier: @identifier
|
|
351
|
+
}
|
|
352
|
+
@consumer.send_command(command)
|
|
353
|
+
@consumer.subscriptions.remove(self)
|
|
354
|
+
# Clear callbacks to release references
|
|
355
|
+
@callbacks = {
|
|
356
|
+
received: nil,
|
|
357
|
+
connected: nil,
|
|
358
|
+
disconnected: nil,
|
|
359
|
+
rejected: nil
|
|
360
|
+
}
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Perform an action on the channel
|
|
364
|
+
# @param action [String] Action name
|
|
365
|
+
# @param data [Hash] Additional data
|
|
366
|
+
def perform(action, data = {})
|
|
367
|
+
idempotency_key = generate_idempotency_key
|
|
368
|
+
payload = data.merge(action: action, _idempotency_key: idempotency_key)
|
|
369
|
+
command = {
|
|
370
|
+
command: "message",
|
|
371
|
+
identifier: @identifier,
|
|
372
|
+
data: JSON.generate(payload)
|
|
373
|
+
}
|
|
374
|
+
@consumer.send_command(command)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Register connected callback
|
|
378
|
+
def on_connected(&block)
|
|
379
|
+
@callbacks[:connected] = block
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Register disconnected callback
|
|
383
|
+
def on_disconnected(&block)
|
|
384
|
+
@callbacks[:disconnected] = block
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Register rejected callback
|
|
388
|
+
def on_rejected(&block)
|
|
389
|
+
@callbacks[:rejected] = block
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Internal: notify subscription connected
|
|
393
|
+
def notify_connected
|
|
394
|
+
# puts "[Cable] Subscription confirmed: #{@identifier}"
|
|
395
|
+
@callbacks[:connected]&.call
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Internal: notify subscription rejected
|
|
399
|
+
def notify_rejected
|
|
400
|
+
# puts "[Cable] Subscription rejected: #{@identifier}"
|
|
401
|
+
@callbacks[:rejected]&.call
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Internal: notify message received
|
|
405
|
+
def notify_received(message)
|
|
406
|
+
@callbacks[:received]&.call(message)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
private
|
|
410
|
+
|
|
411
|
+
# Generate a unique idempotency key for message deduplication
|
|
412
|
+
def generate_idempotency_key
|
|
413
|
+
RNG.uuid
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|