stoneware

Documentation

CLI and builds

Dev server, production build, and what each command actually emits.

terminalsh
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 report

Development

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.

terminalsh
$ 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.

the overlaytxt
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.
terminalsh
$ 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 B

Sizes 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
production output

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)
the same throwing route, built four ways

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.

terminalsh
$ 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.

terminalsh
$ 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.

terminalsh
$ 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 config

The 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 sandbox
what each mechanism covers

Both 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.