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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -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/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. 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