stoneware

Documentation

What's new

0.2.0 — a sitemap you do not maintain by hand, serving from more than one process, shared-cache safety, and several silent failures that now say something.

0.2.0

Four additions and one behaviour change worth reading before you upgrade. Most of this came out of measuring the framework rather than using it, which is why two entries are about things that were quietly wrong rather than missing.

sitemap()

The scaffold used to ship a route holding a hand-written list of paths and a comment asking you to keep it current. That is a sitemap that is wrong by the second page, and wrong is worse than absent — a sitemap listing URLs that 404 is a signal search engines act on.

routes/sitemap.xml.tsts
import { sitemap } from "stoneware";
import { SITE_URL } from "../lib/site.ts";
import { POSTS } from "../lib/posts.ts";

export function GET(): Response {
  return sitemap(
    [
      { url: "/", changeFrequency: "weekly", priority: 1 },
      ...POSTS.map((post) => ({
        url: `/blog/${post.slug}`,
        lastModified: post.published,
      })),
    ],
    { origin: SITE_URL },
  );
}

It owns the parts that are easy to get wrong by hand: XML escaping, absolute URLs, W3C date formats, the value ranges the schema allows, duplicate removal, and the 50,000-URL ceiling. An apostrophe is legal in a URL and must be escaped in XML, which is why Bun.escapeHTML is the wrong tool here — and why a hand-rolled version usually validates until the first query string with an ampersand in it.

What it deliberately does not do is enumerate your routes. The framework knows every pattern, so it could — but it cannot know which ones belong in a sitemap. A checkout confirmation, a page behind a login, an archive you would rather have crawled through links: all routes, none of them entries. Deciding what should be indexed is an editorial call, and guessing would produce a file that is confidently wrong.

Passing a path with no origin is refused rather than emitted, because a relative loc parses fine and no crawler can use it.

Serving from more than one process

A single Bun.serve uses one core. The new workers setting runs several processes behind a shared port and lets the kernel spread connections across them. One process is still the default, and nothing scales itself at runtime.

stoneware start --workers 4      # or WEB_CONCURRENCY=4
                                # or workers: 4 in stoneware.config.ts
                                # or workers: "auto" for one per core

Linux only, and it says so out loud. reusePort is accepted by Bun.serve on every platform and load-balances on one. Measured on Bun 1.3.14: on Windows two processes bind the same port without error and the first receives every connection — thirty requests, thirty answered by the same worker. On Linux, four hundred requests spread 112 / 90 / 104 / 94 across four workers.

  Windows                        Linux
  ─────────────────────────      ─────────────────────────
  worker A  ██████████ 30        worker A  ███ 104
  worker B  ·           0        worker B  ███  90
  worker C  ·           0        worker C  ███  94
  worker D  ·           0        worker D  ███ 112

  binds, reports success,        what the option is for
  serves from one
the same option, two platforms

So on any platform where that is true, the count falls back to 1 and prints the reason. N processes taking N times the memory and serving from one of them is worse than one process, and it is invisible unless something says so.

Measured on Linux: roughly 1.6x to 2.2x the requests per second at two to four workers. It is not linear, and the ceiling is unmeasured — the load generator shared the machine with the server, so the client gives out before the server does. A real number needs a second machine, and this one is a floor.

Workers share nothing. A counter or cache in a module-level variable becomes one copy per worker, and consecutive requests from one visitor may be answered by different ones. Anything that has to be consistent belongs in a database — or in the environment, as the CSRF secret already is.

That last part is load-bearing rather than incidental. A CSRF token issued by one worker verifies on another because both read the same secret from the environment, which is exactly why the secret has to keep coming from there. Checked across 1, 2, 4 and 8 workers: every token accepted, forged and missing tokens still refused.

Two silent failures now say something

A route that renders its own document with no head element used to lose the bundled stylesheet, the whole of its head() export — title, canonical, Open Graph, JSON-LD — every preload, and the CSP meta tag on export. The page rendered unstyled and unindexable, and nothing anywhere said why. It looked like a CSS bug.

  <html lang="en">
    <body>          ← no <head> to inject into
      ...
    </body>
  </html>

  dropped:  stylesheet · title · canonical · og:* · JSON-LD
            preloads · CSP meta
  said:     nothing
what the page lost, silently

It now warns, naming the route and listing exactly what was dropped — in production as well as development, because the consequence is a live page with no styling and no metadata and the person running it is the one who needs to know. Warned once per route rather than once per request, since a warning on every request under load is its own outage.

The second: a document whose html element is not the first thing in its output — a comment or stray text ahead of the tag — was treated as a fragment and wrapped in a second document, producing nested html and body elements. That one warns in development, where it belongs, since it is an authoring mistake rather than a deployment one.

Neither changes what is rendered. They are diagnostics, not repairs; changing the output would break anyone relying on the current behaviour.

Static serving does far less work

Every page request paid a synchronous existsSync against public/ that was always going to miss, and serving a file cost four filesystem round trips with nothing cached. Serving a stylesheet was measurably slower than rendering a whole page.

                    before     after
  ───────────────────────────────────────
  page     p50       0.142ms   0.071ms
           p99       1.071ms   0.222ms
  asset    p50       0.465ms   0.149ms
           p99       4.287ms   1.458ms
framework request handling, measured in process

Read that honestly: it halves the framework's own cost and does not measurably change end-to-end throughput, because the framework is roughly a seventh of an HTTP request and the rest is the runtime's socket handling. The tail over HTTP is Bun's — a bare Bun.serve returning a fixed string has about 2.2x the p99 of a bare node:http server doing the same thing. That is not something a framework can fix from the inside.

The public/ listing is read once at startup in production, never in development, and a directory containing symlinks opts out of indexing entirely rather than guess at what a link points to. It is a negative filter and nothing more: a path it does not contain is answered as a miss, and a path it does contain goes through every traversal, symlink and dotfile check exactly as before.

A shared cache can no longer mix visitors up

Every cacheable page now carries Vary: Cookie, Authorization. Without it a shared cache keys on the URL alone, and any route that reads a session cookie becomes a way to hand one visitor another visitor's page — measured against a cache in the "cache everything" configuration, three of three visitors were served the page of whoever arrived first.

  before                          after
  ────────────────────────        ────────────────────────
  alice     -> alice              alice     -> alice
  bob       -> alice              bob       -> bob
  carol     -> alice              carol     -> carol
  anonymous -> alice              anonymous -> guest
one route, three visitors

Sent on every page rather than only where a cookie was read, because Vary describes the resource and not the request: a response cached from a visitor who sent no cookie would otherwise be handed to one who did. Static assets deliberately do not carry it — they are identical for every visitor, and fragmenting a CDN's key for them would cost reuse and buy nothing. A genuinely public page still measured four of five requests served from cache.

It costs something. A site whose visitors carry a per-user analytics cookie gets a distinct cache key each, so shared-cache reuse for those visitors drops toward nothing. Cookie-less visitors still share one entry. The alternative was a page that is only conditionally the right person's.

An asset that changes is no longer served with a stale validator

The asset metadata cache introduced earlier in this release remembered the ETag as well as the resolved path. A file replaced under a running server then kept its old tag: the new bytes went out with the old validator, and a client holding it revalidated to 304 for as long as the process lived. Correct content, stale validator, silent, and permanent from the client's side.

Now only the resolved path is remembered, which is the expensive half — the symlink check alone costs about ten times what reading size and mtime does. The validator is read for each response, so it always describes the bytes being sent.

The dev server watches for cross-request state

A signal declared at module scope is one instance per server process, shared by every request it answers. Reading one during a render is how islands share state and is safe; writing one is a cross-user data leak. On a two-route fixture, a request carrying no parameters at all was served the previous visitor's identity and basket, and two concurrent requests each rendered the other's data.

The renderer now remembers what each signal held last time and reports when that changes underneath it, naming the element and the values. Once per signal, development only, and by comparison rather than by wrapping signal() — so it costs the browser nothing and production one boolean check per rendered signal. See islands for the safe pattern.

One behaviour change to know about

In production, the list of files in public/ is read once at startup. A file written into it by a running process — an upload, say — is not served until the server restarts. Contents changing is handled: an existing file that is replaced is served with a validator that matches its new bytes. Development is unchanged and re-reads on every request.

For the normal case, where public/ is part of what you deploy, none of this is visible. If you write user uploads into public/ at runtime, serve them from a route or object storage instead — which is where they belonged anyway, since a second worker would not see them either.

Also in 0.2.0

  • The built server entry now goes through serve() rather than calling Bun.serve itself. It was a second definition of how the server boots, and the multi-process path silently did not apply to it.
  • listen() refuses allowPortFallback and reusePort together. They want opposite things when a port is busy, and quietly picking one would make a clustered server bind a different port per worker and look like it was working.
  • A non-numeric WEB_CONCURRENCY is ignored rather than fatal. It comes from a platform, not from your project, and refusing to boot over it would trade a working single process for an outage.
  • 629 tests, up from 519.

Earlier versions

0.1.8, 0.1.7 and 0.1.6 each have their own page, 0.1.5 and 0.1.4 share one, 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.