//// 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