Documentation
CLI and builds
Dev server, production build, and what each command actually emits.
stoneware dev # dev server with hot reload
stoneware build # production build
stoneware start # run the production server bundle
stoneware export # prerender every page to static HTML
stoneware preview # serve an export the way a static host would
stoneware routes # print the route table, in match order
stoneware doctor # check the project setup
stoneware --version # both versions, for a bug reportDevelopment
One process serves pages, built island chunks, and the live-reload socket. There is no second dev server and no proxy. Editing a file under routes/, islands/ or lib/ rebuilds and reloads the browser.
If the port is already taken, dev moves to the next free one and says so — a busy port in development is nearly always your own previous run. Production does the opposite and fails loudly, because a platform routes traffic to the port it assigned and quietly binding a different one produces a service that looks healthy in its own logs while every request from outside fails.
Dev also asks whether anything is already answering on the port before it binds, rather than only reacting to a failed bind. The case that needed it: dev binds localhost and start binds 0.0.0.0, which are different sockets, so two projects could each hold :3000 with neither seeing an error — and requests went to whichever one the client's IPv4/IPv6 preference picked.
$ stoneware dev --port 3000 --open
[stoneware] something is already serving on port 3000, trying 3001
[stoneware] dev server on http://localhost:3001--open launches a browser at the served URL. Only on a first start, never on a hot reload — the dev server re-evaluates its own module on every save, so opening unconditionally would spawn a tab per keystroke.
When something breaks
A failed rebuild appears in the browser, not only in the terminal. Without that, a build error leaves the page serving stale output with nothing to indicate it — and the only notice is a line in a window you may not be looking at.
Build failed
islands/Counter.tsx:3:10
Expected "}" but found "null"
return null;
^It clears on the next successful build. A thrown route gets the same treatment on the server side: the built-in 500 page renders the stack in development, so the thing you need is in front of you. Production shows neither the message nor the stack.
Both respect the default CSP. The overlay styles itself through the CSSOM, which the policy does not govern, and the error page is deliberately unstyled — relaxing style-src to prettify an error would mean developing against a policy production does not use.
Bun.build rejects with an AggregateError whose own message is the unhelpful string "Bundle failed"; everything useful — file, line, column, source text — is on the messages inside it. The dev server unpacks that rather than forwarding the summary.
Production
- One server bundle, with every route and island statically imported so no transpilation happens per request.
- One content-hashed client chunk per island, plus a shared runtime chunk.
- One content-hashed stylesheet, collected from every .css under routes/, islands/ and lib/.
- An island manifest, so the server serves pre-built chunks instead of rebuilding at boot.
On 0.1.3 and earlier, route modules are inlined into the server bundle but path matching still uses Bun.FileSystemRouter, so routes/ must exist at runtime. From 0.1.4 the build writes a pattern table instead and the source tree is no longer needed to serve.
$ stoneware build
[stoneware] build complete in 156ms
server .stoneware/server.js
routes 4
islands 3
Counter 247 B
Badge 191 B
@runtime 45 B
total 483 BSizes are reported per island. JavaScript being opt-in is only a claim you can check if the cost is shown next to the name of the thing that caused it.
What gets minified, and what deliberately does not
minified source maps ────────────────────────────────────────────────── island chunks fully none stylesheet fully — server bundle whitespace only emitted and linked
The two are treated differently because the questions are different. An island chunk is downloaded by every visitor and never read from a stack trace, so every byte counts and identifiers do not. The server bundle is downloaded by nobody and read from stack traces whenever something breaks in production, so the reverse holds.
none 270 KB at Boom (routes/boom.tsx:3:14) whitespace 221 KB at Boom (routes/boom.tsx:3:14) + syntax 213 KB at Boom (routes/boom.tsx:2:22) + identifiers 199 KB at e8 (routes/boom.tsx:2:22)
Stripping whitespace is free: 18% off with the frame, line, column and error text all identical to an unminified build. Past that, syntax minification constant-folds — which moved the reported line and rewrote the message from value.missingProperty to null.missingProperty, pointing at the wrong thing — and identifier mangling turns the frame into e8. Source maps recover neither, so the last 10% is not taken.
Seeing the route table
Nothing about two filenames says which one a request reaches first. stoneware routes prints the compiled table in the order patterns are actually tried — literal before dynamic before catch-all — along with whether each is a page or a server action.
$ stoneware routes
/api/echo POST routes/api/echo.ts
/blog/[slug] GET routes/blog/[slug].tsx
/plain GET routes/plain.tsx
/ GET routes/index.tsx
4 route(s), listed in match order.Reserved routes — _404, _500, _middleware — are listed rather than hidden. They are real files doing real work, and leaving them out invites the conclusion that they were not picked up. A module that fails to import is reported as unknown instead of taking the listing down: a route list is most useful precisely when something is broken.
Checking the setup
stoneware doctor checks the things a running server cannot check for you. A missing CSRF secret already stops production from starting with a message that names it, so doctor does not re-check it; what it covers is the class of problem that surfaces later as something apparently unrelated.
$ stoneware doctor
ok Bun 1.3.14
ok stoneware 0.1.5
FAIL tsconfig compilerOptions.jsxImportSource is "react", expected "stoneware"
JSX will compile against React's runtime. This does not fail at build
time - it fails mid-render as a TypeError about an object, pointing at
a template that is fine.
ok routes/ with an index route
warn .gitignore does not cover .env
1 error(s), 1 warning(s).It exits non-zero on an error so it is usable in CI, and zero on a warning — failing a pipeline over a judgement call teaches people to stop running it.
Static export
stoneware export prerenders every page to a directory of plain HTML files. It builds first, then fetches each route through the ordinary request pipeline — the same router, the same renderer — so what lands on disk is what the server would have sent, with one deliberate addition covered below. There is no second rendering path to drift.
$ stoneware export --out dist
[stoneware] exported 12 page(s) in 486ms
output /srv/my-site/dist
skipped /subscribe (renders a CSRF token)
csp embedded in every page, and written to _headers
frame-ancestors 'none' needs a real header —
_headers covers Netlify and Cloudflare Pages, other hosts need configThe output has no runtime requirement at all, which is the point: it deploys to Cloudflare Pages, Netlify, GitHub Pages or any CDN — hosts that cannot run Bun and so cannot run a Stoneware server.
The policy an exported site carries
A Content-Security-Policy is a response header, and a directory of files cannot carry one. Before 0.1.4 that meant an exported site had no policy at all until the host was configured to send it — the framework's strongest default, silently absent, with nothing to indicate it.
stoneware start header everything, frame-ancestors included
export → _headers header everything, on hosts that read the file
(Netlify, Cloudflare Pages)
export → <meta> in the markup everything except frame-ancestors,
report-uri and sandboxBoth are written, because neither is sufficient alone. _headers is inert on a host that does not read it; a meta tag works anywhere, including GitHub Pages, but browsers ignore three directives when they arrive that way. Those three are stripped from the tag rather than emitted, because a policy that lists frame-ancestors and does not enforce it advertises protection the page does not have.
So on Netlify and Cloudflare Pages an export is protected exactly as the server would protect it. Anywhere without header support you keep everything except clickjacking protection, and the export names what is missing rather than reporting parity.
The meta tag is placed after any charset declaration and before the first stylesheet, preload or script. Both constraints are real: a charset has to land within the first 1024 bytes, and a meta policy only governs what is declared after it.
Environment
Bun reads .env natively, so Stoneware has no dotenv dependency. create-stoneware generates a .env with a unique STONEWARE_CSRF_SECRET and gitignores it, leaving .env.example as the tracked template. A real environment variable beats .env.local, which beats .env.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.