Dev mode
brust dev rebuilds when you save, swaps the new build in without dropping the old one, and shows build errors above the last good page. A browser overlay explains what each page was made of. None of it exists under brust start.
Run it
$ cd my-app
$ brust dev
$ brust dev routes.tsx --port 3000 --workers 2The entry is routes.tsx unless you name another file. --port sets the port you open in the browser (0 picks a free one); --workers sets the Bun workers behind it.
Dev mode uses one worker unless you ask for more, and reads the host and port from the same configuration as brust start, with --port on top.
brust dev --target cloudflare is a different loop (it runs wrangler). It is described on the CLI page.
What happens when you save
The process you start is a small proxy. It owns your port and forwards every request to a child: a normal brust start running on a loopback port of its own.
- A file changes. After a 150 ms pause the watcher starts one build. A change that arrives while a build runs is kept and builds next: nothing is dropped, and two builds never overlap.
- The build runs as its own process into a fresh folder under
.brust/dev/, so nothing from the previous build is cached in memory. - A new child starts on that build and the proxy waits for it to print
[brust] ready(60 seconds at most). - Only then does the proxy start sending requests to the new child, tell the open pages to reload, and stop the old child. Until that moment the old one kept serving.
So a build that fails, or a new child that does not come up, never takes the site down: the old child stays active. The server is never reloaded in place; every build gets a fresh one.
Every attempt takes a number, so a failed build uses one up: in the output below a syntax error was build 3 and its fix is gen 4.
[brust] dev server on http://127.0.0.1:4873 — [r]eload [o]pen [w]arnings [c]lear [q]uit
[brust] building http://127.0.0.1:4873 gen 0
[brust] change detected: .
[brust] ready http://127.0.0.1:4873 gen 1 (55ms)
GET / 200 2ms
[brust] change detected: pages/HomePage.tsx
[brust] building http://127.0.0.1:4873 gen 1 (55ms)
[brust] ready http://127.0.0.1:4873 gen 2 (46ms)
[brust] css public/app.css (no reload)
[brust] change detected: pages/HomePage.tsx
[brust] building http://127.0.0.1:4873 gen 2 (46ms)
[brust] build BuildMessage: Expected identifier but found end of file
[brust] error http://127.0.0.1:4873 gen 2 (46ms) — build BuildMessage: Expected identifier but found end of file
[brust] change detected: pages/HomePage.tsx
[brust] building http://127.0.0.1:4873 gen 2 (46ms)
[brust] ready http://127.0.0.1:4873 gen 4 (51ms)Before the first build is ready, the page you open is a short building…
notice (status 503) that reloads on its own.
Try it
The same loop with the lines it prints. Nothing is built here: each button applies the rules above to the numbers on the left.
| Serving | gen 1 |
|---|---|
| Builds tried | 1 |
| State | ready |
| Browser | first load |
[brust] dev server on http://127.0.0.1:1337 — [r]eload [o]pen [w]arnings [c]lear [q]uit
[brust] ready http://127.0.0.1:1337 gen 1What reloads and what does not
The proxy keeps a WebSocket open to every page and tells it what to do. There are three outcomes.
| You change | What the proxy does | The page |
|---|---|---|
public/*.css | Copies the file into the running build and skips the build. | Swaps the stylesheet link. No reload, no state lost. |
A stylesheet you import (a global file or a CSS Module) | Rebuilds and boots a new child as usual. | Swaps each built stylesheet link when the old and new builds have the same stylesheets one to one. If one was added or removed, the page reloads. |
Anything else: a page, a component, a loader, brust.toml | Rebuilds, boots a new child, swaps it in. | Reloads. |
A page that was open while the dev server was down reconnects on its own (retrying every 150 ms, backing off to 2 seconds) and reloads if it is behind the build that is now serving.
The watcher ignores node_modules, .git, .wrangler, .brust, dist, editor swap and temporary files, and database files (.sqlite, .db).
Build errors
When a build fails, the pages already open show the error as an overlay: the rule, the file and position when there is one, and the build's own text. The page under it is the last good build, still working.
The overlay is a shadow-root element, so the page's CSS cannot touch it and it cannot touch the page. It goes away by itself on the next successful build. A tab you open while the build is broken shows it at once.
If your own code returns a server error for a page, the proxy shows it as a plain page with the status and the response body, and the reload channel stays connected.
The terminal
In a terminal, brust dev prints request lines that scroll normally and keeps a short status block at the bottom (never more than six lines, and it never takes over the screen). When the output is not a terminal, or CI or NO_COLOR is set, you get plain lines only, as in the log above.
● ready http://127.0.0.1:1337 gen 3 · built in 46ms
last: GET / → 200 · 2 static 1 native 0 react
[r]eload [o]pen [w]arnings [c]lear [q]uitThe second line is the last page you loaded and how many of its components were static, native and React (taken from that page's trace). While the build is broken it shows the error instead, and a count of the warnings the build printed.
✖ error http://127.0.0.1:1337 gen 2 · built in 46ms
error: build Expected identifier but found end of file
warnings: 1 — hook-unsupported components/X.tsx:12:26 hook useReducer is not supported
[r]eload [o]pen [w]arnings [c]lear [q]uit| Key | Does |
|---|---|
r | Reloads every open page. |
o | Opens the dev URL in your browser. |
w | Prints the warnings of the last build (or says there are none). |
c | Clears the screen and redraws the status block. |
q or Ctrl-C | Stops the build, the child and the proxy, and removes .brust/dev. |
The devtools overlay
Every HTML page served through the dev proxy carries <brust-devtools>, a small indicator in the bottom-right corner that reads like ready · 2s 1n 0r: the build state and how many components of this page are static, native and React. Click it, or press Alt+Shift+B, to open the panel; Esc closes it.
While focus is inside a text field, the shortcut does not open the panel (the field takes the key). Click the indicator instead.
| Panel | Shows |
|---|---|
| Page | The route and its pattern, the params, status, cache result, loader and render time, the number of worker calls, and a render error if there was one. |
| Components | The layouts and page that rendered, each with its tier and source file. A React component also shows why the compiler chose React, and its warnings. Hover or focus a row to outline that component on the page. |
| Jobs | The jobs this render called, whether each was a cache hit, and its key and tags. A cache hit page says no jobs ran. |
| Store | For each store on the page, the server-rendered seed and the live values of its signals. |
| Islands | Each React island and whether it has hydrated. |
| Build | The build state and generation, the current error, and every warning of the build with its file, rule and remediation. |
| Requests | The last 50 page and action requests the dev proxy saw, newest first, with status, time and cache result. |
It works from the keyboard: the arrow keys, Home and End move between tabs, and focus returns to where it was when you close it. It lives in a shadow root, so it does not change your page's styles, and your styles do not change it.
The dev trace
The panels read a trace of each page: which route matched, which components rendered, which jobs ran. The Rust core records it only when the request carries a secret that brust dev made up at start-up and gave to its child, and answers it in an x-brust-dev header (base64 of JSON, 32 KiB at most; a bigger one is cut down and flagged truncated). A trace is never cached.
The proxy adds the secret to every request it forwards (replacing any value a browser sent), reads the trace header and removes it. The browser never sees the header: the proxy puts the trace into the page and keeps the last 50.
{
"route": { "id": "r1", "pattern": "/", "chain": ["layout_9e14e5d1", "homePage_76d6b8d1"], "params": {} },
"status": 200,
"cache": "-",
"loader_ms": null,
"jobs": [],
"jobs_more": 0,
"truncated": false,
"render_ms": 0.4255,
"render_error": null,
"bun_calls": 0,
"rendered": ["layout_9e14e5d1", "homePage_76d6b8d1", "counter_4a7ef039"]
}Three dev-only URLs on the dev port expose the same data: the #__brust_trace script in each traced page, /_brust/dev/traces (the last 50 requests), and /_brust/dev/meta.
/_brust/dev/meta is dev.json, written only by brust build --dev (which brust dev runs for you). It maps each component id in the trace to its source file, its tier, why the compiler chose React when it did, and the build's diagnostics for it.
{
"version": 1,
"components": {
"layout_9e14e5d1": { "file": "components/Layout.tsx", "tier": "static", "diagnostics": [] },
"homePage_76d6b8d1": { "file": "pages/HomePage.tsx", "tier": "static", "diagnostics": [] },
"counter_4a7ef039": { "file": "components/Counter.tsx", "tier": "native", "diagnostics": [] }
}
}What is dev only
brust start and brust build without --dev never include any of this: no overlay, no client script, no trace header, no dev.json. A production server has no dev token, so it records no trace.
The dev server answers only to its own names: loopback, the host you configured, and any names in BRUST_DEV_ALLOWED_HOSTS (comma separated, for a tunnel or a local alias). A request with another Host header gets a 421, so a web page on another site cannot reach your app or your source paths through the dev port by pointing its own name at 127.0.0.1. The reload WebSocket refuses a page from another origin.
$ curl -s -o /dev/null -w "%{http_code}\n" -H 'Host: evil.example' localhost:4874/
421Dev mode is for your machine. Do not expose the dev port to a network you do not trust: the build error overlay and the trace carry file paths and source positions.