stoneware

Documentation

How it works

The path a request takes, and what hydration does to the DOM.

Stoneware is small enough to hold in your head. This page is the whole of it: one request pipeline, one render pass, and one hydration step.

The request pipeline

Every response leaves through a single function, which is what makes the security headers structural rather than something each route remembers.

  Request
     │
     ├─ /_stoneware/*  ─────────────────►  built island chunk        (Bun.file)
     │
     ├─ matches public/  ──────────►  static asset              (Bun.file)
     │
     ├─ router.match(pathname)
     │      └─ no match  ──────────►  404
     │
     ├─ CSRF verify        ◄─────── every non-GET request, before any
     │      └─ invalid  ───────────►  403      handler can observe it
     │
     ├─ action route  ─────────────►  POST/PUT/DELETE handler ──┐
     │                                                          │
     └─ page route                                              │
            │                                                   │
            ├─ component(props)  ──►  VNode tree                 │
            ├─ renderToString    ──►  HTML string  (escaping)    │
            ├─ buildDocument     ──►  + payload + scripts        │
            │                                                   │
            └───────────────┬───────────────────────────────────┘
                            ▼
                  security headers applied   ◄── single exit point
                            │
                            ▼
                        Response
one request, start to finish

The render pass

A template is called once. It returns an inert { type, props } record, which the renderer walks depth-first, appending to a string. There is no previous tree, no diff, and nothing retained afterwards.

  <Page />
     │
     │  Bun transpiles TSX ──► jsx("div", { children: [...] })
     ▼
  VNode  { type, props }        inert data, no methods, no instance
     │
     │  renderToString walks it once
     ▼
  ┌──────────────────────────────────────────────┐
  │  string        ──► escaped via Bun.escapeHTML │
  │  number        ──► escaped                    │
  │  signal        ──► .value, then escaped       │
  │  raw("...")    ──► emitted verbatim  ◄── the only way through
  │  function type ──► called, result walked      │
  │  island        ──► marked + props collected   │
  └──────────────────────────────────────────────┘
     │
     ▼
  HTML string
render-once-to-string

What the server sends

An island is server-rendered with its real initial state, so there is no flash of empty content. Three things go into the response: the markup, a props payload, and one module script per distinct island.

response body, abridgedtxt
<button class="counter"
        data-stoneware-island="LiveCounter"    <-- which island
        data-stoneware-id="stoneware-1">            <-- which instance
  fired <b>0</b> times
</button>

<script type="application/json" id="stoneware-islands">
  [{"name":"LiveCounter","id":"stoneware-1","props":{}}]
</script>

<script type="module" src="/_stoneware/LiveCounter-6dhtkfqt.js"></script>

The markers sit on the island's own root element rather than a wrapper, so the served HTML has no extra node and no layout impact. That is why an island must render exactly one element at its root.

The payload is type="application/json", which browsers never execute, and its <, >, & and U+2028/9 are escaped. Nothing user-controlled is ever concatenated into executable script source.

What hydration does to the DOM

  BEFORE                              AFTER
  ──────                              ─────
  <button data-stoneware-id="stoneware-1">      <button data-stoneware-id="stoneware-1">
    "fired "                            "fired "        ◄─ static text
    <b>                                 <b>
      "0"                                 "0"  ◄────────── Text node, now
    </b>                                </b>              bound by effect()
    " times"                            " times"
  </button>                           </button>
                                        ▲
  inert markup                          └─ click listener attached

  count.value++
      │
      └─► effect fires ─► node.data = "1"     one text node written
                                              no re-render, no diff
server output, then the same DOM after hydration

The client runtime builds the tree once, replaces the marked element, and attaches a subscription to the exact text node or attribute that depends on each signal. Updating a signal does not call the component again.

  island bundle loads
     │
     ├─ read #stoneware-islands  ──► parsed as data, never evaluated
     │
     ├─ for each { name, id, props } matching this island:
     │      │
     │      ├─ component(props)  ──► VNode tree
     │      ├─ mountTree(vnode)  ──► real DOM nodes
     │      │      │
     │      │      ├─ signal child      ──► Text node + effect()
     │      │      ├─ signal attribute  ──► effect() -> setAttribute
     │      │      ├─ style object      ──► CSSOM (CSP-safe)
     │      │      └─ onClick           ──► addEventListener
     │      │
     │      └─ querySelector([data-stoneware-id]).replaceWith(tree)
     │
     └─ done. No further work until a signal changes.
the client runtime, end to end

Why there is no reconciler

A virtual DOM earns its cost when you re-render a whole tree and need to find what changed. Stoneware never re-renders a tree, so there is nothing to compare. The dependency graph that would justify a reconciler is already provided by signals, which is why reusing them rather than writing one is the project's firmest scope boundary.

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