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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. 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