stoneware

Documentation

What's new

0.1.7 — deployed sites keep their CSS, third parties get a policy that extends, the export checks its own links, and errors name the component.

0.1.7

Not published yet. npm still installs 0.1.6, which is what this site runs on. Six changes: two deploy failures, two diagnostics, and two things you could not do at all. Every one of them surfaced while building and deploying a real site rather than in a test.

A deployed site keeps its CSS and its islands

  before                          now
  ──────────────────────────      ──────────────────────────
  GET /             200           GET /             200
  GET /_stoneware/                GET /_stoneware/
      styles.css    404               styles.css    200
      Counter.js    404               Counter.js    200

  a site that renders,            a site that renders
  unstyled and inert              and works
the same deploy, before and after

The server finds island chunks and the stylesheet under .stoneware/static/ — a path it works out at runtime. A platform that builds a function by tracing imports cannot see a path that is worked out, so the bundle arrived and the assets did not. Every page answered 200 with correct markup, and every stylesheet and script on it answered 404.

That shape is why it went unnoticed for three releases while the ones around it were being fixed. Nothing crashes, nothing is logged, and the deploy reports success — it presents as a CSS problem in your own project rather than a missing file.

stoneware build --target vercel now copies the built chunks into public/_stoneware/. public/ is a platform convention rather than a Stoneware one, so it ships without being traced, and the assets are served from the CDN instead of through a function invocation — cheaper and faster than the arrangement that was failing.

the build says what it copiedsh
$ stoneware build --target vercel

  target   vercel
  entry    server.js
  config   vercel.json
  assets   10 file(s) copied to public/_stoneware/
  • The directory is emptied before each copy. Chunk filenames carry a content hash, so merging would accumulate every chunk from every previous build and grow the deployment forever.
  • Add public/_stoneware/ to .gitignore. It is build output, rewritten on every build — new projects get the rule already.
  • A request for /_stoneware/* now falls through to public/ when the build directory is not there. Where the assets are in their usual place nothing changes, and hashed files keep their year-long immutable caching either way.
Fourth in a family: routes/ rescanned at runtime, then islands.json, then stoneware.config.ts, and now the chunks themselves. Every one was a path assembled while running, and every one was invisible until something tried to ship the result somewhere else.

The renderer now names the component

  before                          now
  ──────────────────────────      ──────────────────────────
  TypeError: Cannot render        Cannot render a plain
  value of type object            object with keys:
                                    id, title, price
    at renderChild
    at renderElement                in <span>
    at renderChild                  in <Price>
    at renderElement                in <ProductCard>
    at renderChild                  in <Home>
rendering a database row straight into markup

Rendering is a depth-first walk, so a stack trace taken inside it is all renderer: renderChild called by renderElement, over and over. Every frame belongs to the framework and none of them names a line you wrote. The walk does know which component it is in — it just knows it on the way down, and the error happens on the way back up. So each component frame now catches, records its own name, and rethrows. The path assembles itself as the error unwinds.

The message also says what the value actually was. "Type object" is equally true of a Date, a database row, a Map and a class instance, and each one needs something different done to it.

  a plain object      lists its keys: id, title, price
  a Date              says to format it first
  a Map or Set        says to render [...value]
  a class instance    names the class
what the renderer says about the value
Keys are named, never values. That is enough to recognise a product row on sight, without putting whatever the row holds into a log line.

An error thrown by your own code keeps its message exactly as written — a database driver's error must not come back with framework prose appended to it. The component path is still collected and the server logs it beside the error instead of inside it.

A failing error page no longer hides the real error

If routes/_500.tsx threw while rendering, the log showed its failure and the original one scrolled past above it. Worse, when both failed the same way — both rendering an object, say — the two were indistinguishable: same message, same renderer-only stack.

both pages failing the same waytxt
[stoneware] routes/_500.tsx threw while rendering the error page.
  This is the error page's own failure, not the one that caused the 500:
  Cannot render a plain object with keys: theme, locale
    in <Banner>

[stoneware] The original error, which is what the 500 was actually for:
  Cannot render a plain object with keys: id, title, price
    in <Price>

The built-in fallback page shows the original error too, not the error page's own. It is a fallback for the page that failed, so the error it displays has to be the one the request actually hit.

Two copies of the signals library no longer break every island

A project that installs @preact/signals-core itself, at a version outside the range the framework resolved, ends up with two copies in node_modules. The framework recognised a signal with instanceof, which compares against one particular copy of the class — so a signal produced by the other copy answered false and reached the renderer as an unrecognised object.

what that producedtxt
TypeError: Cannot render an instance of a.
  in <span>
  in <QuoteBadge>

Which is about as unhelpful as an error gets: a minified class name from inside a dependency, blamed on an island that is correct, on a project where nothing looks wrong. The library brands its own instances with Symbol.for("preact-signals"), and a registry symbol is identical across copies, so that is what the check uses now. instanceof stays as the first test because it is the common case and the cheaper one; the brand is the fallback that makes the answer right.

  • Applied on both sides — the server renderer and the client DOM builder — because a mismatch between them is how an island ends up checked on first paint and unchecked on every update afterwards.
  • A value from a second copy is still escaped. Arriving from another copy of the library does not make it trusted.
  • An object that merely has a value property is still refused. The brand is the whole test, or any object with the right shape would render silently.
Still import from stoneware/signals and leave @preact/signals-core out of your package.json — one copy remains the right number. This makes the two-copy case survivable rather than correct. See islands.

Third-party services without giving up the policy

Adding Google Analytics, Stripe or Sentry used to mean retyping the entire Content-Security-Policy as a string, because csp took a string or nothing. A policy retyped by hand to allow one origin is a policy with object-src 'none' or base-uri 'self' missing from it, and nothing anywhere reports the omission.

stoneware.config.tsts
csp: {
  scriptSrc: ["https://www.googletagmanager.com"],
  connectSrc: ["https://www.google-analytics.com"],
  imgSrc: ["https://www.google-analytics.com"],
}

Each list is added to the default rather than replacing it, so 'self' survives, img-src keeps its data:, and every directive you did not mention is byte-identical to the one you would have got with no configuration at all. The string form still replaces the policy outright and csp: false still removes it — both remain the explicit way to take the whole thing over.

  • A directive the default policy does not list — frameSrc, workerSrc — is created seeded with 'self', because that is what it was inheriting from default-src. Without that, allowing Stripe's frame would block your own.
  • A source containing a semicolon, comma or whitespace is refused rather than concatenated. A semicolon ends the directive and starts another, which is how an origin read from an environment variable could append script-src 'unsafe-inline' to a policy that never asked for it.
  • Resolved to a policy string once, when the config loads, so the response header, the meta tag an export embeds and the _headers file it writes all carry the same thing — a static export cannot drift from a served one.
You still do not need 'unsafe-inline' for analytics. The vendor's inline bootstrap snippet is the only part that seems to require it; move those few lines into a file under public/ and 'self' already covers them. See security.

The export checks its own links

  before                          now
  ──────────────────────────      ──────────────────────────
  skipped  /items/[id]            skipped  /items/[id]
  exit 0                          7 link(s) point at pages
                                  this export did not write:
  a site deploys whose own          /items -> /items/first
  navigation 404s, and the          /items -> /items/second
  first to notice is a visitor    exit 0, or 1 with --strict
an export with a dynamic route that has no staticPaths

The skipped line was always printed and is easy to read past: one line among several, informational in tone, on a command that exits 0. Once the pages are written their links can be resolved against the very directory about to be uploaded, so the export now names anything that resolves to nothing — a page never written, a typo in an href, a missing asset.

stoneware export --strict exits 1 when any route was skipped or any link dangles, so CI fails instead of the site. Not the default, because a project may legitimately prerender some routes and serve others; and either way the pages that can be written still are. See static export.

What this cost

Nothing measurable, on the second attempt. The first version recorded every element as well as every component, which meant a try/catch around every element in the tree — 38% of a full page render, measured, which would have given back more than the renderer gained in 0.1.6.

Components are far rarer than elements, so component frames are recorded by catching and the innermost element is recorded by two field writes instead. The path names every component plus the element the value landed in; the elements in between are left out deliberately, and a test asserts they stay out.

The microbenchmark that first said try/catch was free had been optimised away by the JIT. The real cost only appeared in a page-shaped benchmark — which is the argument for measuring the thing you actually ship rather than the thing you can isolate.

Earlier versions

0.1.6 has its 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.