stoneware

Documentation

Configuration

Every option in stoneware.config.ts, its default, and the environment variable that overrides it.

A project needs no config file. Every option below has a default that produces a working, secure app, with one exception — a CSRF secret in production — and that one refuses to start rather than guessing.

stoneware.config.tstsx
import { defineConfig } from "stoneware";

export default defineConfig({
  port: 3000,
  trustProxy: "proto",
});

defineConfig is an identity function. It exists for type inference and nothing else, so a plain object export works identically and you lose only autocomplete.

Where the app lives

  root          process.cwd()     the project directory
  routesDir     routes            pages and actions
  islandsDir    islands           the only source of client JS
  publicDir     public            served byte-for-byte
  outDir        .stoneware        build output
paths — all resolved against root

Set root explicitly in anything that is not started from the project directory — a test, a script, an embedded server. Everything else is relative to it, so getting root right is the only one that matters.

Renaming these is supported and rarely a good idea. routes/ and islands/ are the convention that tells a reader which files ship JavaScript; a project that calls them something else has to explain that in its README instead.

Network

  port        3000                     PORT wins over the config file
  hostname    localhost in dev         HOST
              0.0.0.0 in production
  workers     1                        WEB_CONCURRENCY
binding

The hostname default flips deliberately. A dev server bound to 0.0.0.0 is reachable from the rest of the network, which nobody asked for; a production server bound to localhost is unreachable from the proxy in front of it, and the platform reports it as a failed health check with no useful error.

PORT beats the config file rather than the other way round, because a platform assigns a port and an app that binds a different one looks healthy in its own logs while every external request fails. --port sets PORT, which is why it wins too.

Security

  csrf.secret       required in production   STONEWARE_CSRF_SECRET
  csrf.expiresIn    86400000  (24 hours)
  csrf.fieldName    _csrf
  csrf.headerName   x-csrf-token
csrf

With no secret, production throws at startup and names the three places one can come from. Development mints an ephemeral one per process and warns: forms rendered before a restart fail verification after it, which is confusing exactly once and then never again.

  csp             a default policy       string | false | CSPSources
  trustProxy      false                  STONEWARE_TRUST_PROXY
  cors            off
  followSymlinks  false
the rest
  • csp as an object adds origins to the default policy per directive rather than replacing it, so 'self' and every directive you did not mention survive. As a string it replaces the policy wholesale. As false it sends none, which is a decision you have to write down.
  • trustProxy: "proto" honours X-Forwarded-Proto only — enough to fix http/https confusion, and safe on any host. true also honours X-Forwarded-Host, which is the dangerous half: a forged host poisons every absolute URL the app emits, so it needs the stronger opt-in.
  • followSymlinks off means a link inside public/ pointing elsewhere on disk is not served. A path is checked lexically before it is opened but a symlink resolves at open time, so the two can disagree. Turn it on for a deliberate layout — a monorepo linking public/shared — and know that anything else writing a link into public/ then serves whatever it points at.
  • cors off means same-origin only. Turning it on without meaning to is how an internal API becomes a public one. See middleware and APIs for the options.

Observation

observe takes a function called once per request with the finished response. Nothing is logged per request without it. stoneware dev installs consoleObserver() when it is unset, so development narrates itself and production stays silent until asked — setting it here replaces that in both.

import { defineConfig, consoleObserver } from "stoneware";

export default defineConfig({
  observe: process.env.NODE_ENV === "production"
    ? (event) => metrics.timing("http", event.durationMs, { route: event.route ?? "none" })
    : consoleObserver(),
});

Environment variables, in full

  PORT                    binding port, beats the config file
  HOST                    binding hostname
  WEB_CONCURRENCY         worker count, beaten by an explicit setting
  STONEWARE_CSRF_SECRET   signing secret
  STONEWARE_TRUST_PROXY   "1", "true" or "proto"
read by the framework

Bun loads .env automatically, so a local file is enough in development and no dotenv package is involved. WEB_CONCURRENCY is what Heroku, Render and Railway already set to describe how many processes a plan's memory allows, which means a deploy can scale without a config change.

A config file that is not there is indistinguishable from a project that has none: the app starts on defaults, and your csp override, cors, trustProxy and observer are all silently absent. If your CSRF secret lives in that file it fails loudly instead — but nothing else does.

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