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/docs/realtime.md ADDED
@@ -0,0 +1,543 @@
1
+ # ActionCable-compatible WebSocket
2
+
3
+ Bring real-time features to your application with Funicular's built-in, ActionCable-compatible WebSocket client. It allows your Pure Ruby frontend to communicate seamlessly with your Rails backend for live updates, notifications, chat, and more.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Creating a Consumer](#creating-a-consumer)
9
+ - [Subscribing to Channels](#subscribing-to-channels)
10
+ - [Sending Messages](#sending-messages)
11
+ - [Handling Disconnection](#handling-disconnection)
12
+ - [Unsubscribing](#unsubscribing)
13
+ - [Complete Example: Chat Application](#complete-example-chat-application)
14
+ - [Best Practices](#best-practices)
15
+
16
+ ## Overview
17
+
18
+ The `Funicular::Cable` module provides a simple API to create consumers and subscribe to channels, handling all the complexities of the ActionCable protocol, including automatic reconnection.
19
+
20
+ ### Protocol Compatibility
21
+
22
+ Funicular's WebSocket client is fully compatible with Rails ActionCable, supporting:
23
+ - Channel subscriptions
24
+ - Message broadcasting
25
+ - Channel actions (perform)
26
+ - Automatic reconnection
27
+ - Ping/pong keepalive
28
+
29
+ ## Creating a Consumer
30
+
31
+ Create a consumer for your Rails app's cable endpoint:
32
+
33
+ ```ruby
34
+ class ChatComponent < Funicular::Component
35
+ def component_mounted
36
+ # Create consumer pointing to Rails ActionCable endpoint
37
+ @consumer = Funicular::Cable.create_consumer("/cable")
38
+
39
+ # Subscribe to channels (see next section)
40
+ end
41
+
42
+ def component_unmounted
43
+ # Clean up (disconnect) when component is unmounted
44
+ @consumer&.disconnect
45
+ end
46
+ end
47
+ ```
48
+
49
+ ### Consumer URL
50
+
51
+ The URL can be:
52
+ - Relative: `/cable` (uses current host)
53
+ - Absolute: `ws://localhost:3000/cable` or `wss://example.com/cable`
54
+
55
+ ## Subscribing to Channels
56
+
57
+ Subscribe to a channel and provide a callback to handle incoming messages:
58
+
59
+ ```ruby
60
+ class ChatComponent < Funicular::Component
61
+ def component_mounted
62
+ @consumer = Funicular::Cable.create_consumer("/cable")
63
+
64
+ # Subscribe to ChatChannel with room parameter
65
+ @subscription = @consumer.subscriptions.create(
66
+ channel: "ChatChannel",
67
+ room: "lobby"
68
+ ) do |message|
69
+ # This block is called whenever a message is received from the server
70
+ handle_message(message)
71
+ end
72
+ end
73
+
74
+ def handle_message(message)
75
+ puts "New message: #{message}"
76
+ patch(messages: state.messages + [message])
77
+ end
78
+ end
79
+ ```
80
+
81
+ ### Channel Parameters
82
+
83
+ Pass parameters to identify the channel subscription:
84
+
85
+ ```ruby
86
+ # Single room chat
87
+ @subscription = @consumer.subscriptions.create(
88
+ channel: "ChatChannel",
89
+ room: "general"
90
+ ) do |message|
91
+ # Handle message
92
+ end
93
+
94
+ # User-specific notifications
95
+ @subscription = @consumer.subscriptions.create(
96
+ channel: "NotificationChannel",
97
+ user_id: current_user.id
98
+ ) do |notification|
99
+ patch(notifications: state.notifications + [notification])
100
+ end
101
+
102
+ # Post comments
103
+ @subscription = @consumer.subscriptions.create(
104
+ channel: "CommentsChannel",
105
+ post_id: props[:post_id]
106
+ ) do |comment|
107
+ patch(comments: state.comments + [comment])
108
+ end
109
+ ```
110
+
111
+ ### Rails Backend (ActionCable Channel)
112
+
113
+ On the Rails side, define the corresponding channel:
114
+
115
+ ```ruby
116
+ # app/channels/chat_channel.rb
117
+ class ChatChannel < ApplicationCable::Channel
118
+ def subscribed
119
+ stream_from "chat_#{params[:room]}"
120
+ end
121
+
122
+ def unsubscribed
123
+ # Cleanup
124
+ end
125
+
126
+ def speak(data)
127
+ # Broadcast message to all subscribers
128
+ ActionCable.server.broadcast(
129
+ "chat_#{params[:room]}",
130
+ {
131
+ user: current_user.username,
132
+ content: data["message"],
133
+ timestamp: Time.now.iso8601
134
+ }
135
+ )
136
+ end
137
+ end
138
+ ```
139
+
140
+ ## Sending Messages
141
+
142
+ Use the `perform` method to send messages/actions to the server:
143
+
144
+ ```ruby
145
+ class ChatComponent < Funicular::Component
146
+ def handle_send_message
147
+ # Send "speak" action to ChatChannel
148
+ @subscription.perform("speak", message: state.message_input)
149
+
150
+ # Clear input
151
+ patch(message_input: "")
152
+ end
153
+
154
+ def render
155
+ div do
156
+ # Message list
157
+ state.messages.each do |msg|
158
+ div { "#{msg['user']}: #{msg['content']}" }
159
+ end
160
+
161
+ # Input form
162
+ input(
163
+ value: state.message_input,
164
+ oninput: ->(e) { patch(message_input: e.target[:value]) }
165
+ )
166
+ button(onclick: -> { handle_send_message }) { "Send" }
167
+ end
168
+ end
169
+ end
170
+ ```
171
+
172
+ ### Action Parameters
173
+
174
+ The `perform` method sends an action to the Rails channel:
175
+
176
+ ```ruby
177
+ # Simple action
178
+ @subscription.perform("speak", message: "Hello!")
179
+
180
+ # Action with multiple parameters
181
+ @subscription.perform("update_typing_status", is_typing: true, user_id: 123)
182
+
183
+ # Action with complex data
184
+ @subscription.perform("edit_message", {
185
+ message_id: 456,
186
+ content: "Updated content",
187
+ edited_at: Time.now.to_s
188
+ })
189
+ ```
190
+
191
+ The Rails channel receives these as method calls:
192
+
193
+ ```ruby
194
+ class ChatChannel < ApplicationCable::Channel
195
+ def speak(data)
196
+ # data = { "message" => "Hello!" }
197
+ ActionCable.server.broadcast(...)
198
+ end
199
+
200
+ def update_typing_status(data)
201
+ # data = { "is_typing" => true, "user_id" => 123 }
202
+ end
203
+ end
204
+ ```
205
+
206
+ ## Handling Disconnection
207
+
208
+ The consumer automatically reconnects when the connection is lost. You can handle connection events:
209
+
210
+ ```ruby
211
+ class ChatComponent < Funicular::Component
212
+ def component_mounted
213
+ @consumer = Funicular::Cable.create_consumer("/cable")
214
+
215
+ # The consumer automatically reconnects on disconnection
216
+ # You can track connection state if needed
217
+ patch(connected: true)
218
+
219
+ @subscription = @consumer.subscriptions.create(
220
+ channel: "ChatChannel",
221
+ room: "lobby"
222
+ ) do |message|
223
+ # Ensure we're still connected when handling messages
224
+ if state.connected
225
+ handle_message(message)
226
+ end
227
+ end
228
+ end
229
+
230
+ def component_unmounted
231
+ patch(connected: false)
232
+ @consumer&.disconnect
233
+ end
234
+ end
235
+ ```
236
+
237
+ ## Unsubscribing
238
+
239
+ Unsubscribe from a channel when no longer needed:
240
+
241
+ ```ruby
242
+ class MultiChannelComponent < Funicular::Component
243
+ def switch_channel(new_room)
244
+ # Unsubscribe from old channel
245
+ @subscription&.unsubscribe
246
+
247
+ # Subscribe to new channel
248
+ @subscription = @consumer.subscriptions.create(
249
+ channel: "ChatChannel",
250
+ room: new_room
251
+ ) do |message|
252
+ handle_message(message)
253
+ end
254
+
255
+ patch(current_room: new_room)
256
+ end
257
+
258
+ def component_unmounted
259
+ # Clean up subscriptions
260
+ @subscription&.unsubscribe
261
+ @consumer&.disconnect
262
+ end
263
+ end
264
+ ```
265
+
266
+ ## Complete Example: Chat Application
267
+
268
+ ```ruby
269
+ class ChatComponent < Funicular::Component
270
+ def initialize_state
271
+ {
272
+ messages: [],
273
+ message_input: "",
274
+ current_room: "lobby",
275
+ online_users: [],
276
+ connected: false
277
+ }
278
+ end
279
+
280
+ def component_mounted
281
+ # Create WebSocket consumer
282
+ @consumer = Funicular::Cable.create_consumer("/cable")
283
+
284
+ # Subscribe to chat channel
285
+ @chat_subscription = @consumer.subscriptions.create(
286
+ channel: "ChatChannel",
287
+ room: state.current_room
288
+ ) do |message|
289
+ handle_chat_message(message)
290
+ end
291
+
292
+ # Subscribe to presence channel
293
+ @presence_subscription = @consumer.subscriptions.create(
294
+ channel: "PresenceChannel",
295
+ room: state.current_room
296
+ ) do |data|
297
+ handle_presence_update(data)
298
+ end
299
+
300
+ patch(connected: true)
301
+ end
302
+
303
+ def component_unmounted
304
+ @chat_subscription&.unsubscribe
305
+ @presence_subscription&.unsubscribe
306
+ @consumer&.disconnect
307
+ patch(connected: false)
308
+ end
309
+
310
+ def handle_chat_message(message)
311
+ patch(messages: state.messages + [message])
312
+ end
313
+
314
+ def handle_presence_update(data)
315
+ patch(online_users: data["users"])
316
+ end
317
+
318
+ def handle_send_message
319
+ return if state.message_input.strip.empty?
320
+
321
+ @chat_subscription.perform("speak", {
322
+ message: state.message_input,
323
+ user: current_user.username
324
+ })
325
+
326
+ patch(message_input: "")
327
+ end
328
+
329
+ def handle_message_input(event)
330
+ new_value = event.target[:value]
331
+ patch(message_input: new_value)
332
+
333
+ # Send typing indicator
334
+ @chat_subscription.perform("typing", {
335
+ user: current_user.username,
336
+ is_typing: !new_value.empty?
337
+ })
338
+ end
339
+
340
+ def render
341
+ div(class: "chat-container") do
342
+ # Header
343
+ div(class: "chat-header") do
344
+ h2 { "Chat Room: #{state.current_room}" }
345
+ span(class: state.connected ? "status-online" : "status-offline") do
346
+ state.connected ? "Connected" : "Disconnected"
347
+ end
348
+ end
349
+
350
+ # Online users
351
+ div(class: "online-users") do
352
+ h3 { "Online (#{state.online_users.length})" }
353
+ state.online_users.each do |user|
354
+ div(class: "user") { user }
355
+ end
356
+ end
357
+
358
+ # Message list
359
+ div(class: "messages", ref: :message_list) do
360
+ state.messages.each do |msg|
361
+ div(class: "message", key: msg["id"]) do
362
+ strong { "#{msg['user']}: " }
363
+ span { msg["content"] }
364
+ small(class: "timestamp") { msg["timestamp"] }
365
+ end
366
+ end
367
+ end
368
+
369
+ # Input form
370
+ div(class: "message-input") do
371
+ input(
372
+ value: state.message_input,
373
+ oninput: :handle_message_input,
374
+ placeholder: "Type a message...",
375
+ class: "input"
376
+ )
377
+ button(
378
+ onclick: -> { handle_send_message },
379
+ disabled: state.message_input.strip.empty? || !state.connected,
380
+ class: "send-button"
381
+ ) { "Send" }
382
+ end
383
+ end
384
+ end
385
+
386
+ def current_user
387
+ # Access current user from session or props
388
+ @current_user ||= props[:current_user]
389
+ end
390
+ end
391
+ ```
392
+
393
+ ### Rails Backend
394
+
395
+ ```ruby
396
+ # app/channels/chat_channel.rb
397
+ class ChatChannel < ApplicationCable::Channel
398
+ def subscribed
399
+ stream_from "chat_#{params[:room]}"
400
+ PresenceChannel.broadcast_presence(params[:room])
401
+ end
402
+
403
+ def unsubscribed
404
+ PresenceChannel.broadcast_presence(params[:room])
405
+ end
406
+
407
+ def speak(data)
408
+ ActionCable.server.broadcast(
409
+ "chat_#{params[:room]}",
410
+ {
411
+ id: SecureRandom.uuid,
412
+ user: current_user.username,
413
+ content: data["message"],
414
+ timestamp: Time.now.iso8601
415
+ }
416
+ )
417
+ end
418
+
419
+ def typing(data)
420
+ ActionCable.server.broadcast(
421
+ "chat_#{params[:room]}_typing",
422
+ {
423
+ user: data["user"],
424
+ is_typing: data["is_typing"]
425
+ }
426
+ )
427
+ end
428
+ end
429
+
430
+ # app/channels/presence_channel.rb
431
+ class PresenceChannel < ApplicationCable::Channel
432
+ def subscribed
433
+ stream_from "presence_#{params[:room]}"
434
+ add_user_to_room
435
+ end
436
+
437
+ def unsubscribed
438
+ remove_user_from_room
439
+ end
440
+
441
+ def self.broadcast_presence(room)
442
+ users = # ... fetch online users for room
443
+ ActionCable.server.broadcast(
444
+ "presence_#{room}",
445
+ { users: users.map(&:username) }
446
+ )
447
+ end
448
+
449
+ private
450
+
451
+ def add_user_to_room
452
+ # Track user presence
453
+ PresenceChannel.broadcast_presence(params[:room])
454
+ end
455
+
456
+ def remove_user_from_room
457
+ # Remove user from tracking
458
+ PresenceChannel.broadcast_presence(params[:room])
459
+ end
460
+ end
461
+ ```
462
+
463
+ ## Best Practices
464
+
465
+ ### 1. Clean Up Subscriptions
466
+
467
+ Always unsubscribe and disconnect in `component_unmounted`:
468
+
469
+ ```ruby
470
+ def component_unmounted
471
+ @subscription&.unsubscribe
472
+ @consumer&.disconnect
473
+ end
474
+ ```
475
+
476
+ ### 2. Handle Connection State
477
+
478
+ Provide feedback when disconnected:
479
+
480
+ ```ruby
481
+ def render
482
+ if !state.connected
483
+ div(class: "alert") { "Reconnecting..." }
484
+ end
485
+
486
+ # Rest of UI
487
+ end
488
+ ```
489
+
490
+ ### 3. Use Specific Channels
491
+
492
+ Create focused channels instead of one large channel:
493
+
494
+ ```ruby
495
+ # ✅ Good: Specific channels
496
+ @consumer.subscriptions.create(channel: "ChatChannel", room: "lobby")
497
+ @consumer.subscriptions.create(channel: "NotificationChannel", user_id: user.id)
498
+
499
+ # ❌ Avoid: Generic catch-all channel
500
+ @consumer.subscriptions.create(channel: "AppChannel")
501
+ ```
502
+
503
+ ### 4. Validate Data on Server
504
+
505
+ Never trust client data:
506
+
507
+ ```ruby
508
+ # Rails channel
509
+ def speak(data)
510
+ # ✅ Validate and sanitize
511
+ message = data["message"].to_s.strip[0..1000] # Limit length
512
+ return if message.empty?
513
+
514
+ ActionCable.server.broadcast(...)
515
+ end
516
+ ```
517
+
518
+ ### 5. Use Keys for Dynamic Lists
519
+
520
+ When rendering messages from WebSocket, use `key` prop:
521
+
522
+ ```ruby
523
+ state.messages.map do |msg|
524
+ div(key: msg["id"]) { msg["content"] } # Efficient re-rendering
525
+ end
526
+ ```
527
+
528
+ ### 6. Debounce Typing Indicators
529
+
530
+ ```ruby
531
+ def handle_typing
532
+ # Clear existing timer
533
+ JS.global.clearTimeout(@typing_timer) if @typing_timer
534
+
535
+ # Send typing=true
536
+ @subscription.perform("typing", is_typing: true)
537
+
538
+ # Set timer to send typing=false after 1s of inactivity
539
+ @typing_timer = JS.global.setTimeout(1000) do
540
+ @subscription.perform("typing", is_typing: false)
541
+ end
542
+ end
543
+ ```