Packages

Native desktop GUI framework for Gleam, powered by Iced

Current section

Files

Jump to
plushie_gleam src plushie dev_server.gleam
Raw

src/plushie/dev_server.gleam

//// Dev server: file watcher and recompiler for live reload.
////
//// Watches source directories for `.gleam` file changes, runs
//// `gleam build`, detects changed BEAM modules, reloads them, and
//// tells the runtime to re-render. The UI updates without losing
//// application state.
////
//// Started automatically when `dev: True` is set in StartOpts.
////
//// Requires the `file_system` Hex package (an Elixir library) as a
//// project dependency, and Elixir installed in your environment.
//// Without it, the dev server starts but file watching is disabled
//// and a warning is logged. See the Getting Started guide for setup.
@target(erlang)
import gleam/dynamic.{type Dynamic}
@target(erlang)
import gleam/erlang/process.{type Subject}
@target(erlang)
import gleam/option.{type Option, None, Some}
@target(erlang)
import gleam/otp/actor
@target(erlang)
import gleam/string
@target(erlang)
import plushie/platform
@target(erlang)
import plushie/runtime
@target(erlang)
const default_debounce_ms = 100
@target(erlang)
const default_watch_dirs = ["src/"]
@target(erlang)
const gleam_extension = ".gleam"
@target(erlang)
const build_dir = "build/dev/erlang"
// -- Types -------------------------------------------------------------------
@target(erlang)
/// Messages handled by the dev server actor.
pub opaque type DevMessage {
/// Debounce timer expired; time to recompile.
Recompile
/// Raw file event from the watcher (decoded from Dynamic).
RawFileEvent(Dynamic)
/// Graceful shutdown: stop the file watcher and exit.
Shutdown
}
@target(erlang)
/// Dev server actor state.
type DevState {
DevState(
runtime: Subject(runtime.RuntimeMessage),
watcher: Option(Dynamic),
debounce_pending: Bool,
last_mtimes: List(#(Dynamic, Dynamic)),
self: Subject(DevMessage),
)
}
// -- Public API --------------------------------------------------------------
@target(erlang)
/// Start the dev server actor, watching src/ for changes.
pub fn start(runtime: Subject(runtime.RuntimeMessage)) -> Nil {
let _result = start_actor(runtime)
Nil
}
@target(erlang)
/// Start the dev server under a supervisor.
///
/// Returns `Started` for use as a supervisor child spec.
pub fn start_supervised(
runtime: Subject(runtime.RuntimeMessage),
) -> Result(actor.Started(Subject(DevMessage)), actor.StartError) {
start_actor(runtime)
}
@target(erlang)
fn start_actor(
runtime: Subject(runtime.RuntimeMessage),
) -> Result(actor.Started(Subject(DevMessage)), actor.StartError) {
// Snapshot initial BEAM mtimes
let initial_mtimes = list_beam_files(build_dir)
// Start file watcher; degrade gracefully if file_system is not installed
let watcher = case start_file_watcher(default_watch_dirs) {
Ok(pid) -> {
file_watcher_subscribe(pid)
platform.log_info(
"plushie dev: watching " <> string.join(default_watch_dirs, ", "),
)
Some(pid)
}
Error(reason) -> {
platform.log_warning("plushie dev: " <> reason)
None
}
}
actor.new_with_initialiser(5000, fn(self: Subject(DevMessage)) {
let initial_state =
DevState(
runtime:,
watcher:,
debounce_pending: False,
last_mtimes: initial_mtimes,
self:,
)
// Receive typed messages; add raw file events only when watching
let selector = process.new_selector() |> process.select(self)
let selector = case watcher {
Some(_) -> selector |> process.select_other(fn(msg) { RawFileEvent(msg) })
None -> selector
}
actor.initialised(initial_state)
|> actor.selecting(selector)
|> actor.returning(self)
|> Ok
})
|> actor.on_message(handle_message)
|> actor.start
}
// -- Public API (shutdown) ----------------------------------------------------
@target(erlang)
/// Send a shutdown message to the dev server actor.
///
/// Stops the file watcher process and exits the actor cleanly.
/// Call this explicitly during runtime shutdown to clean up the
/// file watcher; OTP supervisor shutdown signals do not trigger
/// custom message handlers.
pub fn stop(subject: Subject(DevMessage)) -> Nil {
process.send(subject, Shutdown)
}
// -- Actor loop --------------------------------------------------------------
@target(erlang)
fn handle_message(
state: DevState,
msg: DevMessage,
) -> actor.Next(DevState, DevMessage) {
case msg {
RawFileEvent(raw) -> {
// file_system sends {file_event, Pid, {Path, Events}}
// Try to extract the path from the raw message
case extract_file_path(raw) {
Ok(path) -> {
case is_gleam_file(path) {
True -> {
case state.debounce_pending {
True -> actor.continue(state)
False -> {
process.send_after(state.self, default_debounce_ms, Recompile)
actor.continue(DevState(..state, debounce_pending: True))
}
}
}
False -> actor.continue(state)
}
}
Error(_) -> actor.continue(state)
}
}
Recompile -> {
let state = DevState(..state, debounce_pending: False)
platform.log_info("plushie dev: recompiling...")
process.send(
state.runtime,
runtime.SetDevOverlay(message: Some("Rebuilding...")),
)
let output = gleam_build()
case string.contains(output, "error") {
True -> {
platform.log_error("plushie dev: build failed:\n" <> output)
process.send(
state.runtime,
runtime.SetDevOverlay(message: Some("Build failed")),
)
actor.continue(state)
}
False -> {
// Detect changed modules by comparing mtimes
let new_mtimes = list_beam_files(build_dir)
let changed = find_changed_modules(state.last_mtimes, new_mtimes)
case changed {
[] -> {
platform.log_info("plushie dev: no modules changed")
process.send(state.runtime, runtime.SetDevOverlay(message: None))
actor.continue(DevState(..state, last_mtimes: new_mtimes))
}
modules -> {
reload_modules(modules)
// ForceRerender clears the dev overlay on success
process.send(state.runtime, runtime.ForceRerender)
platform.log_info("plushie dev: reload complete")
actor.continue(DevState(..state, last_mtimes: new_mtimes))
}
}
}
}
}
Shutdown -> {
case state.watcher {
Some(pid) -> stop_file_watcher(pid)
None -> Nil
}
platform.log_info("plushie dev: stopped")
actor.stop()
}
}
}
// -- Helpers -----------------------------------------------------------------
@target(erlang)
/// Check if a path is a Gleam source file worth watching.
/// Excludes _build/ and build/ only when they appear as directory
/// boundaries, not as substrings of other directory names.
fn is_gleam_file(path: String) -> Bool {
string.ends_with(path, gleam_extension)
&& !string.contains(path, "/_build/")
&& !string.starts_with(path, "_build/")
&& !is_build_output_dir(path)
}
@target(erlang)
/// Check if a path is inside a top-level build output directory.
/// Matches "/build/dev/" or "build/dev/" but not "/my_build/".
fn is_build_output_dir(path: String) -> Bool {
string.contains(path, "/build/dev/") || string.starts_with(path, "build/dev/")
}
@target(erlang)
/// Extract a file path from a raw file_system event.
@external(erlang, "plushie_dev_server_ffi", "extract_file_path")
fn extract_file_path(msg: Dynamic) -> Result(String, Nil)
@target(erlang)
/// Find modules whose mtimes changed between two snapshots.
@external(erlang, "plushie_dev_server_ffi", "find_changed_modules")
fn find_changed_modules(
old: List(#(Dynamic, Dynamic)),
new: List(#(Dynamic, Dynamic)),
) -> List(Dynamic)
@target(erlang)
/// Run `gleam build` and return the output.
@external(erlang, "plushie_ffi", "gleam_build")
fn gleam_build() -> String
@target(erlang)
/// Reload a list of module atoms (purge + load_file).
@external(erlang, "plushie_ffi", "reload_modules")
fn reload_modules(modules: List(Dynamic)) -> Nil
@target(erlang)
/// List .beam files in a directory, returning (module_atom, mtime) tuples.
@external(erlang, "plushie_ffi", "list_beam_files")
fn list_beam_files(dir: String) -> List(#(Dynamic, Dynamic))
@target(erlang)
/// Start a file_system watcher on the given directories.
/// Returns an error if the file_system package is not installed.
@external(erlang, "plushie_ffi", "start_file_watcher")
fn start_file_watcher(dirs: List(String)) -> Result(Dynamic, String)
@target(erlang)
/// Subscribe the calling process to file events from the watcher.
@external(erlang, "plushie_ffi", "file_watcher_subscribe")
fn file_watcher_subscribe(pid: Dynamic) -> Nil
@target(erlang)
/// Stop the file_system watcher process.
@external(erlang, "plushie_ffi", "stop_file_watcher")
fn stop_file_watcher(pid: Dynamic) -> Nil