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.
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
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_CONCURRENCYThe 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
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
- 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"
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.