Rabbit Slide Show

Funicular: A Browser App Framework Powered by PicoRuby.WASM

2026-04-22

Description

Presentation slide for RubyKaigi 2026

Text

Page: 1

Funicular
A Browser App Framework
Powered by PicoRuby.WASM
hasumikin
22nd April #rubykaigiA

Page: 2

self.inspect
Hitoshi HASUMI @hasumikin (GitHub and Twitter)
Creator of PicoRuby
Committer of mruby and mruby/c
(Committer of CRuby IRB and Reline)
Working with ANDPAD: Local Meals Sponsor
Today, I’ll be handing out Lucky Pierrot Burger
🍔

Page: 3

Today’s Topic
Chapter 1. PicoRuby.WASM
Chapter 2. Funicular on PicoRuby.WASM

Page: 4

Chapter 1
PicoRuby.WASM

Page: 5

PicoRuby consists of
PicoRuby compiler using Prism
mruby VM + Task scheduler (mruby-task mgem)
A lot of libraries (Picogems)
Microcontroller integration: Raspi Picos and ESP32
Browser-based runtime: PicoRuby.WASM ←[new!]

Page: 6

How to Use PicoRuby.WASM
<script src="https://cdn.jsdelivr.net/npm/@picoruby/
wasm-wasi@latest/dist/init.iife.js"></script>
<!-- Embedded Ruby Script -->
<script type="text/ruby">
puts "Hello, World!"
</script>
<!-- Remote Ruby Script file (.rb) -->
<script type="text/ruby" src="hello.rb"></script>
<!-- Remote Precompiled Ruby VM Code file (.mrb) -->
<script type="application/x-mrb" src="hello.mrb"></script>

Page: 7

When It Comes to PicoRuby,
💡
L-chika
(LED blinking)

Page: 8

Words from Ancient Izumo Folklore
L-chika is aesthetic/emotional education.
Lチカは情操教育
If you love your kid, let them L-chika.
かわいい子にはLチカをさせろ
We’re talking about the browser..?

Page: 9

Demo : L-chika in browser

Page: 10

L-chika in Ruby (microcontroller)
led = LED.new(id: 'red')
while true
led.on
sleep 1
led.off
sleep 1
end

Page: 11

In JavaScript
const led = new LED({ id: 'red' });
setInterval(() => {
led.on();
setTimeout(() => {
led.off();
}, 1000);
}, 2000);

Page: 12

Or… (I don’t know if it works)
const led = new LED({ id: 'red' });
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
(async () => {
while (true) {
led.on();
await sleep(1000);
led.off();
await sleep(1000);
}
})();

Page: 13

🤔
sleep

Page: 14

Demo : sleep in CRuby.WASM

Page: 15

sleep in PicoRuby.WASM
Claude Code:
“sleep doesn’t work on browser, let me investigate
how PicoRuby.wasm manages setTimeout()…”
Me:
“PicoRuby.wasm’s sleep doesn’t block browser UI”

Page: 16

Kernel#sleep in *Ruby.WASM
CRuby essentially depends on the OS
→Kernel#sleep doesn’t work on CRuby.WASM
PicoRuby basically does NOT depend on the OS
→PicoRuby.WASM supports sleep
But How?

Page: 17

RubyKaigi 2025
🍊
https://rubykaigi.org/2025/presentations/hasumikin.html

Page: 18

PicoRuby.WASM’s main loop
Module.picorubyRun = function() {
const MRB_TICK_UNIT = 4;
// Must match the value in build_config/picoruby-wasm.rb
const BATCH_DURATION = 16;
// ~1 frame (16.67ms) at 60 fps
const MAX_CATCHUP_TICKS = 10; // Cap to avoid freeze when tab returns from background
let lastTick = performance.now();
function run() {
const now = performance.now();
let tickCount = 0;
while (now - lastTick >= MRB_TICK_UNIT && tickCount < MAX_CATCHUP_TICKS) {
Module._mrb_tick_wasm();
lastTick += MRB_TICK_UNIT;
tickCount++;
}
if (now - lastTick >= MRB_TICK_UNIT) {
lastTick = now;
}
const sliceStart = performance.now();
while (performance.now() - sliceStart < BATCH_DURATION) {
const result = Module._mrb_run_step();
if (result < 0) {
console.error('mrb_run_step returned', result, '- scheduler continues');
}
}
setTimeout(run, 0);
}
run();
};

Page: 19

PicoRuby.WASM’s main loop
function run() {
(...)
const sliceStart = performance.now();
while (performance.now() - sliceStart < BATCH_DURATION) {
const result = Module._mrb_run_step();
(...)
}
setTimeout(run, 0);
}
run();
setTimeout() is called every 16ms (60fps)

Page: 20

sleep in PicoRuby.WASM
It is NOT setTimeout() wrapper
PicoRuby runtime runs on a multi-task scheduler
Kernel#sleep stops its task and returns to the
scheduler
In PicoRuby.WASM, JS main loop is the scheduler
sleep works like yield

Page: 21

Yield == Give Way
Kernel#sleep(n) suspends
the task and gives way to
other tasks
After n sec, scheduler
resumes the task
https://www.callbigmike.com/faqs/what-does-yield-mean-in-driving/

Page: 22

Yield == Give Way
Claude Code: “Now I understand full picture!”
———next session———
😡
Claude Code: “sleep shouldn’t work on browser…
Me: “Hey! ”

Page: 23

CRuby.WASM vs PicoRuby.WASM
|
CRuby.WASM
| PicoRuby.WASM
================|=====================|===============
`Kernel#sleep` |
|
----------------|---------------------|---------------
Multithreading | No `Thread` support |`Task` support
----------------|---------------------|---------------
Binary size
|
17.2 MB
|
1.1 MB
(compressed) |
(4.4 MB)
|
(470 KB)
❌
✅

Page: 24

Debugging
Unfortunately, Opal is hard to debug
You won’t use PicoRuby.WASM unless it’s
debuggable

Page: 25

Demo : binding.irb in L-chika

Page: 26

Chrome Web Store

Page: 27

Break by binding.irb
binding.irb (Ruby)
│
struct wasm_debug_state g_gdb; // Global
└─ mrb_binding_irb (C)
1. g_gdb.mode = WASM_DBG_PAUSED
2. g_gdb.binding = binding (save from GC)
3. Specify file & line by tracing back callstack
4. mrb_suspend_task() (like sleep)

Page: 28

Step and Next
mrb_debug_step() or mrb_debug_next()
1. g_gdb = WASM_DBG_STEP (or WASM_DBG_NEXT)
2. mrb->code_fetch_hook = wasm_hook
3. mrb_resume_task()
wasm_hook() checks line and ci (stack) on every OP
code fetch

Page: 29

Eval
Disable code_fetch_hook()
debug_env_cxt_clear() to clear on-stack cxt
__b__ = $__debug_binding__
red = __b__.local_variable_get(:red)
__r__ = (USER_CODE) # mrb_execute_proc_synchronously()
__b__.local_variable_set(:red, red)
__r__
# `_` in IRB
debug_env_cxt_restore() to restore cxt
Enable code_fetch_hook()

Page: 30

Chrome Extension
<script src="https://cdn.jsdelivr.net/npm/@picoruby/
wasm-wasi@debug/dist/init.iife.js"></script>
<!--
^^^^^ You need to use @debug -->

Page: 31

Chapter 2
Funicular on PicoRuby.WASM

Page: 32

Demo : Tic-Tac-Toe (ReactJS)

Page: 33

Demo : Tic-Tac-Toe on
Funicular

Page: 34

Funicular
Single-Page Application framework
Virtual DOM + Diffing
Class-based Components
Ruby DSL with no JavaScript
Routing, State Management, etc.

Page: 35

Demo : TODO APP

Page: 36

How to Write Funicular: e.g. TODO APP
class TodoApp < Funicular::Component
def initialize_state
{ todos: [], input: "" }
end
def handle_input(event) # Update state
patch(input: event.target[:value])
end
def handle_add(event) # Add new TODO
new_todo = { id: Time.now.to_i, text: state.input, done: false }
patch(todos: state.todos + [new_todo], input: "")
end
def render
div do
h1 { "Todo List" }
input(value: state.input, oninput: :handle_input)
button(onclick: :handle_add) { "Add" }
state.todos.each do |todo|
div(key: todo[:id]) { todo[:text] }
end
end
end
end
Funicular.start(TodoApp, container: 'app')

Page: 37

How to Patch
Component#patch
└─ Component#component_will_update # If callback exists
@state.merge(normalized_state)
Component#re_render
└─ patches = VDOM::Differ.diff(@vdom, new_vdom)
VDOM::Patcher.new.apply(@dom_element, patches)
Component#component_updated
# If callback exists
(Nothing special)
Funicular is implemented in pure Ruby

Page: 38

Funicular: When to Use?
🤔

Page: 39

Funicular: When NOT to Use?
Need performance: WASM overhead
Large scale with complex state management:
We still don’t have the best practice
Reliability required: Nobody uses it yet
SEO critical: No SSR support (for now…but…)

Page: 40

Finally, We Have
Truly Practical
Full-Stack Ruby

Page: 41

Ruby for Server?

Page: 42

Demo : Keyboard

Page: 43

Server: PicoRuby on Keyboard
kb = Keyboard.new([12,11,10,9,8], [7,6,5,4,3,2]) # GPIO matrix
kb.add_layer { ... } # Set keymap
pixels = Array.new(NUM_LEDS, 0) # LED color
Task.new do # Expose `pixels` via dRuby over WebSocket
DRb.start_service('ws://0.0.0.0:9090', pixels)
DRb.thread.join
end
kb.on_tick do |_kb| # Callback from the main loop
# Light LEDs according to the pixels
end
kb.start do |event| # Main loop
USB::HID.keyboard_send(event[:modifier], event[:keycode])
end

Page: 44

Client: PicoRuby on Browser
pixels = DRb::DRbObject.new_with_uri("ws://#{ip}:9090")
new_pixels = [...] # 30 Integer
pixels.replace(new_pixels)
Both PicoRuby (microcontroller) and
PicoRuby.WASM support dRuby!!
(via WebSocket in this case)

Page: 45

Keyboard is Essentially Ruby
Second time since 2021

Page: 46

Ideas for Full-Stack Ruby
CRuby for AWS Lambda function
PicoRuby for IoT device (Pi Pico and ESP32)
Supports WebSocket & MQTT w/ TLS and BLE
🤔
You can expose config via dRuby for development
PicoRuby.WASM for browser configurator
“Funicular”

Page: 47

🎼
Funiculí-funiculá funiculí-funiculá
https://ja.wikipedia.org/wiki/ヴェスヴィオ
🎶

Page: 48

Funicular is a Cable-Driven Railway
Obviously targeting
🛤️
But RubyKaigi talks aren’t supposed to be
about

Page: 49

Demo : Chat APP

Page: 50

🛤️
🛤️
I’ve Been Workin’ on the Railroad
🛤️
integration: Initializer and Asset pipilne
Object-REST Mapper: Funicular::Model
ActionCable-compatible WebSocket
-like form builder
Debugging mode vs Production mode

Page: 51

👉
🛤️
To Be Continued
Kaigi on (if accepted)

Page: 52

🌟
Stargaze at
github.com/picoruby/picoruby
👀
Tic-Tac-Toe in depth
picoruby.org/wasm

Other slides