Dev server
Watch, rebuild, reload — the loop you leave running while you work.
go run . builds the site once. While you work you want that on a loop — edit a
file, see the page. That's devserver: it serves the build directory, watches
your sources, rebuilds on change, and reloads the browser. Stdlib Go only.
Documentation
Run
import "github.com/LukasDerBaum42/astwerk/devserver"
devserver.Run(context.Background(), devserver.Config{
Build: devserver.Steps(
devserver.Command("templ", "generate"),
devserver.Command("go", "run", ".", "--dev"),
),
})
Run builds once at startup, serves Dir, and rebuilds whenever a watched
file changes. Only Config.Build is required.
Build must be a subprocess
The build is two commands, not a Go function — and that is the most important
thing on this page. A running Go program cannot load new code, so an in-process
build would keep re-rendering the components compiled into the binary that
started the server: saving a .templ would reload the browser and change
nothing, with no error. A subprocess recompiles.
Command treats a non-zero exit as a failed build and puts the command's output
in the error, so a compiler message lands in the browser overlay, file and line
included. Steps runs its steps in order and stops at the first failure.
Pass --dev to the inner build: a normal build deletes the output directory
first, so a browser fetching an asset mid-rebuild gets a 404 for a file that
exists a moment later.
When an in-process build is fine
A closure is right only when the build reads nothing but data — markdown, CSS, images. The rule: anything that involves compiled Go code must shell out.
Config
Only Build is required.
| Field | Default | Meaning |
|---|---|---|
Build |
— | rebuilds the site; called once at startup and on every change |
Dir |
"build" |
the directory to serve |
Addr |
"localhost:8080" |
listen address; "localhost:0" picks a free port |
Watch |
["."] |
directories watched for changes |
Exts |
devserver.DefaultExts |
extensions that trigger a rebuild |
Ignore |
devserver.DefaultIgnore |
directory names never descended into |
Interval |
300ms |
how often the watcher polls |
Log |
stderr | where build and reload messages go |
NoColor |
false |
turn off ANSI colour (automatic off a terminal, and on NO_COLOR) |
Ready |
— | called with the bound address once listening; mainly for tests |
Dir is added to the ignore list automatically, or the build's own output would
trigger the next build forever. DefaultExts covers everything that can change
the output (.go, .templ, .md, .html, .css, .js, .ts, .toml,
.json, .yaml, .yml, .svg); DefaultIgnore is .git, node_modules,
.cache, vendor.
Watching
The watcher polls file size and modification time every Interval rather than
using OS file events. That keeps astwerk dependency-free and immune to editors
that save by renaming a temp file. A burst of changes is followed by one quiet
tick, so saving five files at once is one rebuild, not five.
What it does that a plain file server doesn't
- Serves the site the way a static host will: directory URLs get a trailing
slash redirect (otherwise relative asset URLs resolve one level too high),
and a missing path gets your own
404.htmlwith a 404 status. - Injects a reload script into every HTML response — nothing is written into
your build output, so
build/stays exactly what you'd deploy. - Shows a failed build as a full-screen overlay instead of a stale page, and clears it on the next successful build.
devserver imports nothing from ssg. You hand it a func() error; it calls
it. A project whose build does more than call ssg.Build — generating a CSS
file, fetching data, running templ generate — gets exactly the same loop.
Example
func main() {
if !*serve {
if err := ssg.Build(tree(), ssg.BuildOptions{Dev: *dev}); err != nil {
log.Fatal(err)
}
return
}
devserver.Run(context.Background(), devserver.Config{
Build: devserver.Steps(
devserver.Command("templ", "generate"),
devserver.Command("go", "run", ".", "--dev"),
),
})
}
go run . --serve