Build local tools — small Lustre apps that run on your machine: file browsers, audio players, config editors, one-off dashboards, and other utilities that need a browser UI wired to localhost, the filesystem, and fast iteration. hot_skeleton strips the boilerplate (HTTP server, WebSocket Lustre component, Tailwind watch, hot code swap) so you can focus on the tool itself.
- 🔥 Hot reload — edit Gleam, keep UI state; no full page refresh
- 🎨 Tailwind — CSS rebuilds in dev via
tailwind_wrapper - 📁 Local files —
GET /reverse-proxy?path=…streams audio, images, and text from disk - 🔌 Lustre server components — one WebSocket, server-rendered vdom
- 🏭 Production —
gleam runwithout the reloader - 📋 Logging — boot lines by default;
HOT_SKELETON_LOG=debugfor HTTP/HMR detail - 🧪 Tests — Gherkin + Chrome scenario for hot-reload state preservation
gleam dev # hot reload + Tailwind watch (port 8080, or $PORT)
gleam run # production entry: src/hot_skeleton.gleam
gleam test # unit tests + hot-reload feature testPoint your app at hot_skeleton.start(component, refresh_msg) and use the
counter example split-module pattern for
hot-swappable update / view (see Technical reference).
gleam dev runs src/hot_skeleton_dev.gleam, which wraps the server with
hot_reload. That starts
radiate on an absolute path to src/ (needed
for fsevents on macOS) and
tailwind_wrapper for CSS. When you edit a component,
radiate recompiles and hot-loads the new module. The browser is not
refreshed; the WebSocket stays open and the lustre-server-component actor's
in-memory model survives — the next message dispatches into the new code.
The server prints [hot_skeleton] listening on … and, when Tailwind is
ready, [hot_skeleton] tailwind -> …. Set HOT_SKELETON_LOG=debug for HTTP
lines, timings, and HMR detail. To log only tailwind_wrapper events to a
file, use the package CLI (tailwind_wrapper/README.md).
GET /reverse-proxy?path=… streams a file from disk via mist.send_file
(audio, images, and common text types). path is absolute or cwd-relative
(URL-encode spaces and special characters).
| Status | Meaning |
|---|---|
400 |
missing path |
404 |
file not found |
415 |
unsupported extension |
Example:
curl "http://localhost:8080/reverse-proxy?path=/tmp/photo.png" -o photo.png
Supported extensions include mp3, m4a, wav, ogg, flac, png, jpg, gif, webp,
svg, txt, md, html, css, json, js, gleam, yaml, and csv. Full map:
streamable_content_type ·
serve_reverse_proxy.
The index page loads /audio_player_with_waveform.js and calls register() so
Lustre custom elements (audio-player-host, audio-player-waveform, …) work
in server-component apps (e.g. filesystem_explorer via viewer_ui/media).
JS and reverse-proxy streams send Cross-Origin-Resource-Policy: same-origin
(harmless without cross-origin isolation; kept for embed compatibility).
Rebuild the bundle after changing
audio_player_with_waveform:
./scripts/build_audio_player_js.shCopies audio_player_with_waveform.js and waveform_worker.mjs into priv/static/.
The host serves both at /audio_player_with_waveform.js and /waveform_worker.mjs
(application/javascript — required for the waveform worker pool).
gleam test runs test/hot_skeleton_test.gleam plus a
dream_test scenario in
test/features/hot_reloading.gleam (Chrome via
chrobot):
- Click
+→ count is1(original+ 1). - Edit
src/examples/counter/logic.gleamso+increments by2. - Count is still
1— no reload, state kept. - Click
+→ count is3(1 + 2 with hot-swapped code).
counter/logic.gleam is restored after the test. One-off setup:
gleam run -m chrobot/install.
Gleam compiles same-module function references to local fun refs
(fun update/2). In Erlang those are pinned to the module version active
when the fun was captured, so code:atomic_load/1 does not update them —
the Lustre actor keeps calling the old update. Use cross-module fun
refs (fun 'examples@counter@logic':update/2), resolved through the code
server on every call.
Two-file pattern: wrapper exposes component(), sibling logic holds
init / update / view:
import examples/counter/logic
import lustre.{type App}
pub type Model = logic.Model
pub type Message = logic.Message
pub fn component() -> App(Nil, Model, Message) {
lustre.simple(logic.init, logic.update, logic.view)
}No compiler plugins or macros — only types and component() live in
counter.gleam; business logic in counter/logic.gleam can change at
runtime.