Appearance
Event
Lune has a unified, bidirectional event bus. Crystal can push events to the frontend, and the frontend can push events back to Crystal — using the same event names on both sides.
For the full API reference see Event plugin.
Crystal → JavaScript
Call app.event.emit with an event name and an optional payload:
crystal
app.event.emit("status-changed", "ready")
app.event.emit("progress", { "percent" => 42 })
app.event.emit("file-saved")Listen in JavaScript with lune.Event.on or lune.Event.once:
js
import { lune } from "../lunejs/runtime/runtime.js";
lune.Event.on("status-changed", (status) => console.log(status));
lune.Event.once("connected", () => showWelcomeMessage());JavaScript → Crystal
Emit from JavaScript with lune.Event.emit:
js
await lune.Event.emit("search", { query: input.value });Listen in Crystal with app.event.on or app.event.once:
crystal
app.event.on("search") do |data|
results = search_index(data["query"].as_s)
app.event.emit("results", results)
endUnified bus
Crystal-emitted events are received by JS listeners and vice versa — names live in one shared namespace:
crystal
# Crystal side
app.event.on("search") do |data|
app.event.emit("results", run_search(data["query"].as_s))
endjs
// JS side
lune.Event.on("results", (data) => renderList(data));
searchButton.addEventListener("click", () =>
lune.Event.emit("search", { query: input.value }),
);Timing
app.event.emit is safe to call from any fiber. Events emitted before the bridge is ready are buffered (up to 64; oldest dropped with a warning on overflow) and flushed once the WebView loads — but for guaranteed delivery, emit from on_load or in response to a JS event.
Crystal app.event.on handlers run on the webview main thread, the same thread Cocoa/GTK/WebView2 use to repaint. Any blocking work inside the handler freezes the UI — a synchronous DB query, slow file read, or HTTP call will visibly stall the window until it returns. Dispatch that work to app.async:
crystal
app.event.on("search") do |data|
query = data["query"].as_s
app.async do
results = search_db(query) # blocking I/O — fine here, off the main thread
app.event.emit("results", results)
end
endThis is the same rule that applies to sync bindings (see Bindings) — the on handler isn't a binding, so async: true doesn't apply; you have to move the work yourself.
Event vs Stream
For high-frequency or ordered data flows, use Stream instead of lune.Event.
| Event | Stream | |
|---|---|---|
| Transport | evaluateJavaScript per call | WebSocket frames |
| Best for | UI signals, one-off notifications | Tickers, log lines, token streams |
See Event plugin for the full API reference including common patterns, TypeScript types, and listener management.