Appearance
Events
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.
Crystal → JavaScript
Emitting from Crystal
Call app.emit with an event name and an optional payload:
crystal
app.emit("status-changed", "ready")
app.emit("progress", { "percent" => 42 })
app.emit("file-saved") # no payloadThe payload can be any Crystal value that serializes to JSON — strings, numbers, booleans, arrays, hashes, or JSON::Serializable structs.
Listening in JavaScript
Import on, once, or off from runtime.js:
js
import { on, once, off } from "../lunejs/runtime/runtime.js";
// Persistent listener
on("status-changed", (status) => {
console.log("New status:", status);
});
// One-shot listener — fires once, then removes itself
once("connected", () => {
showWelcomeMessage();
});JavaScript → Crystal
Emitting from JavaScript
Import emit from runtime.js and call it with an event name and an optional payload:
js
import { emit } from "../lunejs/runtime/runtime.js";
await emit("search", { query: input.value });
await emit("user-action", "button-clicked");
await emit("ready"); // no payloademit is async — it resolves once Crystal has received the event.
Listening in Crystal
Register handlers on the app object using on, once, or off:
crystal
# Persistent handler
app.on("search") do |data|
query = data["query"].as_s
results = search_index(query)
app.emit("results", results)
end
# One-shot handler — fires once, then removes itself
app.once("ready") do |_|
puts "Frontend is ready"
end
# Remove all handlers for an event
app.off("search")The data argument is a JSON::Any — use .as_s, .as_i, .as_a, [], etc. to extract values.
Unified event bus
Crystal-emitted events are received by JS listeners; JS-emitted events are received by Crystal listeners. The names live in one shared namespace, so you can design clean back-and-forth flows without separate channels:
crystal
# Crystal side
app.on("search") do |data|
results = run_search(data["query"].as_s)
app.emit("results", results) # reply on the same logical channel
endjs
// JS side
on("results", (data) => renderList(data));
searchButton.addEventListener("click", () => {
emit("search", { query: input.value });
});Removing listeners
JavaScript:
js
const handler = (data) => console.log(data);
on("tick", handler);
// Remove this specific handler
off("tick", handler);
// Remove ALL handlers for this event
off("tick");Crystal:
crystal
# off removes all Crystal-side handlers for the event
app.off("search")Common patterns
Progress reporting (Crystal → JS)
Run a long task in a fiber and stream progress back:
crystal
@[Lune::Bind(async: true)]
def process_files(paths : Array(String)) : Nil
paths.each_with_index do |path, i|
do_work(path)
@app.emit("progress", {
"done" => i + 1,
"total" => paths.size,
"path" => path,
})
end
endjs
on("progress", ({ done, total, path }) => {
progressBar.value = done / total;
statusLabel.textContent = `Processing ${path}...`;
});
await api.Files.ProcessFiles(selectedPaths);Search / command dispatch (JS → Crystal → JS)
The frontend emits a request; Crystal handles it and emits back the response:
crystal
app.on("search") do |data|
query = data["query"].as_s
results = search_index(query)
app.emit("search-results", results.map(&.to_h))
endjs
on("search-results", (results) => renderResults(results));
searchInput.addEventListener("input", (e) => {
emit("search", { query: e.target.value });
});Real-time updates from a background task
crystal
app.async do
loop do
app.emit("cpu-usage", system_cpu_percent)
sleep 1.second
end
endSignalling from JS when the frontend is ready
crystal
app.once("frontend-ready") do |_|
# Safe to emit initial data — frontend is listening
app.emit("config", load_config.to_h)
endjs
import { emit, on } from "../lunejs/runtime/runtime.js";
on("config", (cfg) => applyConfig(cfg));
// After your app has mounted and listeners are registered
emit("frontend-ready");Timing
app.emit is safe to call from anywhere — app.async background tasks, async binding callbacks, or the main thread. Events emitted before the WebView has opened are silently dropped; emit from on_load or in response to a JS event to guarantee delivery.
Crystal app.on handlers run synchronously on the webview main thread. Keep them short. For anything long-running, dispatch the work to a background task:
crystal
app.on("search") do |data|
query = data["query"].as_s
app.async do
results = run_search(query)
app.emit("results", results.map(&.to_h))
end
endTypeScript
All event functions are declared in runtime.d.ts. Callbacks receive unknown by default — cast to your expected type:
ts
import { on, emit } from "../lunejs/runtime/runtime.js";
interface SearchPayload {
query: string;
}
interface SearchResult {
title: string;
url: string;
}
on("search-results", (data) => {
const results = data as SearchResult[];
renderResults(results);
});
const search = (query: string) =>
emit("search", { query } satisfies SearchPayload);