stoneware

Documentation

v0.1.6

Error boundaries, a request hook, a request path about three times faster, and a dev server that stopped breaking itself.

0.1.6

Published 15 August 2026. Two features and three fixes, and the fixes are the reason it followed 0.1.5 so quickly: one of them is the third instance of a bug that only ever shows up on a deploy, and one had been breaking the dev server since the dev server existed.

One widget can fail without taking the page

  before                          now
  ──────────────────────────      ──────────────────────────
  500 — the whole page            200 — the page intact,
  unwinds to _500.tsx             a short note where the
                                  widget was
  a malformed row in one
  widget costs the article        and the error still
  around it                       reaches your logs
a component throws mid-page
the whole APItsx
import { Boundary } from "stoneware";

<Boundary fallback={<p>Reviews are unavailable right now.</p>}>
  <Reviews productId={product.id} />
</Boundary>

Server-side only, and cheap for a reason: rendering is one synchronous walk to a string, so catching is a try around a subtree. No error state, nothing to reset, no second pass, and no JavaScript shipped. See error boundaries.

notFound() is deliberately not caught — it is a routing decision travelling as an exception, and swallowing it would render a fallback with a 200. And a caught error is never silent: it goes to the console always, and onto the request's observe event as event.caught, so a degraded page is one you know about rather than one that merely looks fine.

It survived the measurement that killed a bigger idea. Rewriting the renderer to append into a shared buffer would have made a discarded subtree need an offset and a truncate; because it returns strings up the tree instead, a subtree that throws has simply produced nothing yet. The rewrite was measured, found worthless, and not made — which is why this one is small.

One hook that sees every request

  before                          now
  ──────────────────────────      ──────────────────────────
  nothing, unless you wrote       one line, from one config
  it into _middleware.ts          key, on every request

  and middleware runs before      200 GET /blog/hello-world
  matching, so the most it          1.1ms  /blog/[slug]
  could ever report was the
  path that was asked for         the pattern, not the path
what a request log could tell you
stoneware.config.tsts
import { defineConfig, consoleObserver } from "stoneware";

export default defineConfig({
  observe: consoleObserver(),
});

stoneware dev installs that observer for you, so development prints a line per request with no configuration. Production installs nothing until you ask — a server that narrates itself by default is a server whose logs you turn off.

The field that makes this worth building into the framework rather than leaving to you is route: the matched pattern, /blog/[slug], not the path that was requested. That is what you group by — one row per route instead of one row per blog post — and middleware cannot produce it, because middleware runs before matching. kind is the same argument: only the pipeline knows whether a 404 came from an unmatched path or from a page that called notFound(), and only it can tell a CSRF rejection apart from an application error.

  page         a route rendered HTML, notFound() included
  action       an HTTP method handler under routes/
  asset        public/ or a built island chunk
  not-found    nothing matched the path
  middleware   _middleware.ts answered instead of the route
  preflight    a CORS OPTIONS, answered before anything else
  rejected     CSRF verification refused it
  error        something threw and reached the exit point
event.kind

rejected is deliberately not error. A rise in CSRF rejections is a security signal — a stale form, a misconfigured proxy, or somebody trying — and averaging it into the 5xx rate hides all three.

sending it somewhere other than the consolets
observe: (event) => {
  metrics.timing("http.request", event.durationMs, {
    route: event.route ?? "unmatched",
    kind: event.kind,
    status: String(event.status),
  });
  if (event.error) Sentry.captureException(event.error);
},

What an observer cannot do

It is handed a finished response and its return value is discarded. See everything, change nothing — for the same reason middleware has no next(): the security headers are applied at a single exit point, and a hook that could rewrite what has already been assembled could remove them.

  • An observer that throws is reported once per process and the request is served normally. A broken logger must not be able to turn a 200 into a 500.
  • An async one is accepted and never awaited, so no response inherits the latency of a metrics backend. A rejected promise is reported rather than left unhandled.
  • stoneware export suppresses it. An export prerenders by fetching through the ordinary pipeline, but nobody is visiting — without this, every build would send a burst of synthetic traffic indistinguishable from the real thing.
The event carries the full URL, query string included. That is where personal data ends up when it ends up anywhere, so strip what you must before forwarding it off the box.

Your configuration now travels with the build

  0.1.4    routes/ rescanned at runtime
  0.1.5    islands.json read at runtime
  0.1.6    stoneware.config.ts imported at runtime

  every one of them a path assembled while
  running, which import tracing cannot see
the same mistake, a third time

The built server loaded your config by building a path from the project root and importing it. Same shape as the two failures before it, and found only because observe is a function — nothing serialised could have carried one, which forced a look at how the config reaches the bundle at all.

This one fails worse than the others, because nothing throws. A config file that is not there is indistinguishable from a project that has none, so the app comes up on defaults: your csp override, cors, trustProxy and observer all silently absent. If your config is where your CSRF secret comes from, it is worse still — the server refuses to start, and the message names the secret rather than the missing file.

The build now writes a static import of stoneware.config.ts into the generated entry, so the bundler inlines it exactly like your routes. Verified the way the last two were: build, move the output, delete routes/, islands/, islands.json and the config itself, then serve from a different directory. A container or VPS that ships the whole directory was never affected by any of the three.

Editing an island no longer breaks the dev server

  before                          now
  ──────────────────────────      ──────────────────────────
  500 on any page with a          200, with the edit applied
  Form, blaming the
  template rather than            one server, one port, for
  the reload                      the life of the process

  plus a second server on         live-reload sockets stay
  the next port — the only        open across the swap
  one that actually works
save a file under islands/, then reload the page

The dev server called Bun.serve again on every hot re-evaluation. The previous one stayed bound, the new one took the next free port, and the browser carried on talking to a server built from the previous module graph. Editing anything under islands/ triggers that re-evaluation, because island modules are imported through the framework's own graph.

Once two graphs were live, every identity check the framework makes started failing across them. csrfToken() read an AsyncLocalStorage the running server had never written to, so any page with a <Form> answered 500 — and the error named the template rather than the reload. Each further edit stranded another server on another port.

There is now one server for the life of the process, handed a new request handler on each re-evaluation rather than binding again. Same port, one live module graph, and the live-reload connections stay open across the swap instead of reconnecting.

This bug is as old as the dev server and cost a restart every time it fired. It went unnoticed because nothing logged it — which is what the request hook above, added in the same release, is for. The first thing it printed was the 500 nobody had seen.

The dev server rebuilds only what changed

  what you edited                 rebuild cost
  ──────────────────────────      ──────────────────────────
  a template (.tsx)               53 ms  ->  0.1 ms
  a file in public/               53 ms  ->  0.0 ms
  a stylesheet                    53 ms  ->   10 ms
  an island or lib/               53 ms  ->   50 ms
time spent rebuilding after one save

Every file change redid all of it: re-import every island, re-bundle every client chunk, re-emit the stylesheet. Editing a template invalidates none of that. Each watched directory now declares what it can actually invalidate — routes/ re-imports templates and only builds a .css, islands/ and lib/ are bundled into chunks, public/ is served as-is and builds nothing.

Measured on a fixture with two islands, so the saving grows with the number you have. Rebuilding the stylesheet is never skipped when the islands rebuild: building the chunks clears the static directory, and a stylesheet left behind would simply be gone.

A faster request path

  0.1.5   ~300 us
  0.1.6    ~80 us

  nothing about the framework's shape changed —
  this is work that was being repeated per request
  and is now done once, or not at all
server time for one page request, same page and machine
  • Every request that reached the router first asked the filesystem whether the path was a static file, and got its answer by catching an exception. Checking existence before resolving links removes a thrown error from the page path — the single largest item, worth roughly 95us here.
  • Tag and attribute names are now classified once and remembered instead of re-derived per occurrence. A 58 kB page renders about 28% faster; attribute-heavy markup about 39%.
  • Route matching is flat rather than linear. At 300 routes it cost 9us per request and now costs 1.1us, because literal paths are looked up rather than scanned.
  • The resolved form of a served directory is worked out once per process rather than once per request, and params are allocated only when a route actually captures one.
Absolute numbers are from Windows, where filesystem syscalls and exceptions are expensive. The shape of each win holds everywhere; the size does not. Every one of these was measured before and after rather than reasoned about — and one change that looked obviously worthwhile on paper, rewriting the renderer to append into a shared buffer, turned out to be worth nothing and was not made.

Also in 0.1.6

  • consoleObserver skips assets by default. One page load is one page request and then every image, stylesheet and island chunk on it; consoleObserver({ assets: true }) includes them.
  • Durations are reported as a float, not a rounded integer. A static render is routinely faster than a millisecond, and rounding would print 0ms for the path the framework exists to make fast.
  • formatEvent is exported, so you can keep the one-line format while sending it somewhere other than the console.
  • stoneware export no longer fires your observer. An export prerenders by fetching through the ordinary pipeline, but nobody is visiting — without this, every build sent a burst of synthetic traffic indistinguishable from the real thing.
  • The server bundle has its whitespace stripped: 18% smaller, at no build-time cost. Island chunks and the stylesheet were already minified; the server bundle was the one output that was not. It stops there rather than going further, because identifier mangling would turn every production stack frame into e8 — see the CLI page for the measurements.

Either side of this one

The current release is on what's new. 0.1.5 and 0.1.4 have their own page, and 0.1.3 and 0.1.2 are on past releases.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.