# webserver-base
[](https://crates.io/crates/webserver-base)
[](https://jsr.io/@todd/webserver-base)
Shared logic for Todd Everett Griffin's web servers, as feature-gated **kits**.
This library is deliberately opinionated. Most of what it does is not
configurable, because most of it has exactly one correct answer — the `<head>`,
the sitemap shape, where the icons come from. A knob that can only ever hold one
value is not flexibility, it is surface area. Projects supply the things that
genuinely differ (their name, their pages, their design) and inherit the rest.
This document is the reference. Every feature the library offers is described
here; if something is missing from this file, it is a documentation bug. Read the
code for low-level detail — it is always the source of truth.
---
## Install
```toml
[dependencies]
webserver-base = { version = "0.2", features = ["pages", "observability", "preset"] }
```
Nothing is enabled by default.
| `templates` | the Handlebars registry, the embedded base layout, the template-data types |
| `analytics` | first-party proxies for Plausible and the Sentry browser SDK |
| `sitemap` | sitemap index and url-set generation |
| `feed` | RSS 2.0, Atom 1.0 and JSON Feed for a site's content stream |
| `observability` | Sentry + tracing, initialised in the one order that works |
| `telegram` | outbound Telegram Bot API notifier |
| `webserver` | the server builder, shared state, bootstrap, the static-asset pipeline |
| `pages` | page declarations producing routes *and* sitemap entries — implies `webserver`, `templates`, `sitemap`, `analytics`, `feed` |
| `preset` | Todd Everett Griffin's identity defaults, including the shared `humans.txt` |
| `full` | all of the above |
---
# The Rust library
## A complete server
```rust
use webserver_base::templates::{
BaseTemplateData, Fallback, GoddtriffinParams, PageTemplateData, ThemeColor,
};
use webserver_base::webserver::{FrontendParams, Pages, WebServer, WebServerError};
use webserver_base::{bootstrap, env};
fn main() -> Result<(), WebServerError> {
bootstrap!(|shutdown| async move {
WebServer::from_env()?
.frontend(FrontendParams::from_env(
BaseTemplateData::goddtriffin(GoddtriffinParams {
project: env::required("MY_PROJECT")?,
description: env::required("MY_DESCRIPTION")?,
base_url: env::required("MY_BASE_URL")?,
social_image: String::from("static/image/social/card.webp"),
theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
theme_fallback: Fallback::Dark,
copyright_start: String::from("1998"),
style_sheets: vec![String::from("static/stylesheet/main.css")],
scripts: vec![String::from("static/script/main.js")],
}),
Pages::new().static_page(PageTemplateData::new("home", "Home", "/"), ()),
)?)
.run(shutdown)
.await
})
}
```
That is the whole file. There is no `Environment::from_env`, no `Observability`
wiring, and no 404 declaration, because those are identical in every project —
[`bootstrap`](#bootstrap) and [`frontend`](#frontend--the-line-between-a-site-and-a-service)
own them.
## `WebServer`
A server needs only a host, a port and an [`Environment`]. Everything else is
opt-in, so a sidecar serving one health check is a valid server.
| `new` / `with_state` | explicit host, port and environment; `with_state` carries app state reachable as `state.app()` |
| `from_env` / `from_env_with_state` | reads `WSB_ENVIRONMENT` (required), `WSB_HOST` (defaults `127.0.0.1` locally, `0.0.0.0` in production) and `WSB_PORT` (defaults `8080`) |
| `body_limit` | request body cap, default 256 KiB — raise it for uploads |
| `root_dir` | where `html/`, `static/` and `cache-buster.json` are read from, default the working directory — see [Testing](#testing-with-into_router) |
| `nest` / `merge` / `nest_service` | your own routes |
| `frontend` | declares this server a website; see below |
| `run` | binds, serves, drains, and runs the state's `AppShutdown` cleanup |
| `into_router` | everything `run` does before binding: the finished `Router`, for `oneshot` tests or embedding |
Always on, no method to enable them: `GET /api/v1/health`, the `/api/v1` prefix
itself, and the graceful-drain window.
On the shutdown signal the server stops accepting connections and returns as
soon as the last in-flight request finishes — with nothing in flight, that is
immediate. `DEFAULT_DRAIN_TIMEOUT` is the ceiling on that wait, not a pause: it
exists because a WebSocket never closes on its own, and reaching it logs a
warning and drops what remained.
### `AppShutdown` — draining your own state
Anything your state holds that would be *lost* at exit — a queued notifier, a
spooled writer, a connection pool — has to be drained while the runtime is still
alive. `run` therefore requires `S: AppShutdown`:
```rust
impl AppShutdown for AppState {
async fn on_shutdown(&self) {
self.telegram.flush(Duration::from_secs(5)).await;
}
}
```
**It is a bound, not a hook you remember to attach.** Adding state without an
implementation is a compile error, which is the point: forgetting cleanup is
otherwise invisible until the deploy where the notification you needed never
arrives. A server with no state gets a blanket no-op and writes nothing.
The library owns the sequencing, so there is no ordering to get wrong and no
result to hold across it. Cleanup starts the moment the signal arrives,
**concurrently with the connection drain and under the same ceiling** — so the
process leaves in the longer of the two, never their sum, and a slow drain
cannot eat the cleanup's budget. That matters: the drain ceiling is already 10
seconds, which is also Docker's default kill grace, so anything running *after*
the drain would be killed rather than run.
Overrunning the ceiling is ordinary cancellation — the future is dropped at its
last `.await` — and it is reported at `error!` by the server, not by your hook.
Your hook cannot report it, because being cancelled is precisely what stops it
running. That is also why `on_shutdown` takes no deadline argument: there is
only ever one ceiling, and it belongs to the library.
**It must be idempotent.** It runs once per server holding the state, so a
binary running several servers off one `Arc<AppState>` calls it once per server.
The library cannot deduplicate that — each `WebServer` builds its own
`WebServerState`, and only your application knows what they share. Guard
anything that would misbehave twice behind a `OnceCell` of your own.
Static assets are **presence-detected**: if a `static/` directory exists under
`root_dir` the server serves `/static` and loads the manifest; if not, it does
neither. There is no `.assets()` call.
### Testing with `into_router`
`into_router(shutdown)` returns exactly the `Router` that `run` serves — pages,
the 404, the well-known documents, the cache policy, tracing and the body limit
— without binding a socket. Drive it with `tower::ServiceExt::oneshot`:
```rust
const BIN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../bin");
let server: WebServer = WebServer::new("127.0.0.1", 0, Environment::Local).root_dir(BIN);
let router: Router = web_server(server, settings).into_router(Shutdown::manual())?;
let response: Response = router.oneshot(Request::get("/").body(Body::empty())?).await?;
```
- **Boot validation still runs**, so `Ok` means `run` would have started. A
frontend test therefore needs the *built* site: point `root_dir` at what your
asset build produces (`bin/`, above, from a workspace member) and build the
assets before `cargo test`. There is no lenient test mode — it would pass
exactly the misconfiguration the boot check exists to catch.
- **`root_dir` is per server, not per process.** `cargo test` runs from the
crate's own directory, and parallel tests can each point at their own tree;
changing the working directory could do neither.
- **Put the builder in your library, not `main`.** A binary-only crate's tests
cannot reach `main`, so they would test a copy of the builder chain. Expose
`fn web_server(server: WebServer, settings: Settings) -> WebServer`: `main`
passes `WebServer::from_env()?` and a test passes one with `root_dir`. Keep
environment reads in `main`, so a test builds `Settings` as plain data instead
of mutating process-wide variables. `template_web_server` is the worked
example.
- **A handler needs no fixtures.** A server without `frontend` boots from any
root, so `WebServer::with_state(..).merge(routes).into_router(..)` hands the
handler a real `WebServerState`. There is deliberately no public constructor
for one: the only state a test can see is one `run` could have built.
- **Not covered:** the drain window and `AppShutdown::on_shutdown` never run.
`shutdown.trigger()` reaches only handlers listening on `state.shutdown()`.
- A `.layer()` added to the returned router is outside what the library
guarantees.
## `frontend` — the line between a site and a service
`WebServer::frontend` says "this server returns HTML to humans." Everything a
website needs then follows automatically, and every required input is a field on
`FrontendParams`, so forgetting one is a compile error rather than a page that
silently ships without a share card.
| `base` | site identity: name, description, origin, social image, theme, copyright |
| `pages` | the routes, which are also the sitemap |
| `analytics` | the Plausible site — every frontend is measured |
| `sentry_browser_dsn` | browser errors, separate from the server's DSN |
Calling it gives you, with no further code: the embedded base layout, the
generated icon set, `site.webmanifest`, `robots.txt`, the sitemap index and its
url sets, first-party analytics, the Sentry browser tunnel, and the pre-paint
theme script.
`FrontendParams::from_env(base, pages)` reads the analytics id and browser DSN
for you.
**The 404 is not a per-project decision.** Every frontend gets
`PageTemplateData::new("404", "404", "/404").with_robots(NOINDEX_FOLLOW)`
automatically — the data is identical everywhere, so the library declares it.
What the page *looks* like is entirely yours, in `html/pages/404.hbs`.
### What a frontend must ship
Each of these is a boot error, because a site missing one is broken in a way no
test catches:
| `html/pages/*.hbs`, at least one | a frontend with no pages is not a frontend |
| `html/pages/404.hbs` | every frontend serves a 404 |
| one icon source — see [Icons](#icons) | the whole set derives from it |
| every declared asset present in the manifest | see below |
### Boot-time asset validation
Every asset a page will reference — site-wide and per-page stylesheets and
scripts, the social image, and all four generated icons — is proved to resolve
before the server binds, and **every** miss is reported at once.
Resolving a missing asset to its un-hashed path would produce a link that 404s
for the visitor and reports nothing to you: invisible from the inside. Failing at
*boot* rather than per-request is the safer half of that trade — a failed request
means someone sees a broken page, a failed boot means the deploy never cuts over
and the previous container keeps serving.
Absolute URLs are exempt: a CDN stylesheet will never be in the manifest, and
that is the one legitimate reason for a path to be absent. Assets a handler looks
up dynamically at request time cannot be enumerated at boot and remain yours to
get right.
## Templates
Templates live in `html/{layouts,pages,partials}` — a fixed convention, not a
setting. A template's name is its file stem, so `pages/home.hbs` is `{{> home}}`;
stems must be unique across all three directories.
### The base layout is embedded
`base` ships **inside this crate**. No project owns a copy, and a project file
that would shadow it is a boot error naming the offender. You may add any other
layout; you may not add this one.
That is the whole point. The `<head>` is pure function — spec conformance, Open
Graph, JSON-LD, icons, resource hints — so it is solved once here and inherited
everywhere, rather than copy-pasted eight times and left to drift.
A page supplies one block and extends it:
```handlebars
{{#*inline "body"}}
<main class="page-home">
<h1>{{project}}</h1>
</main>
{{> footer}}
{{/inline}}
{{> base}}
```
The block is named `body`, not `main`, because it *is* everything inside
`<body>` — your header, your `<main>`, your footer. The layout contributes no
chrome at all: `<body>` is bare, and what a page looks like is entirely yours.
### What the layout emits
`<meta charset>` first, then viewport, title and description — the highest-value
crawler signals as high in the document as they go. Then `robots` and
`canonical`; `color-scheme` and one `theme-color` per scheme; the full Open Graph
block with an **absolute, content-hashed** `og:image` plus its real width, height
and MIME type; four X/Twitter tags (the rest provably fall back to Open Graph);
the icon set and manifest link; JSON-LD; the pre-paint theme script; auto-derived
`preconnect` hints; then stylesheets. Scripts go at the end of `<body>`.
### Template data
Three types, split by lifetime.
**`BaseTemplateData`** — per-server, what is true of every page. Built once, held
in server state. `BaseTemplateDataParams` names every field so two cannot be
transposed. `with_*`, `replace_*` and `extend_*` methods override afterwards.
A leading slash on `social_image` is stripped on the way in, so it keys into the
cache-buster map exactly like the stylesheet and script paths beside it.
**`PageTemplateData`** — per-render, what this page is. `new(template, page_name,
page_url)` plus optional overrides:
| `with_description` / `with_social_image` / `with_social_image_alt` | override the site default for this page |
| `with_robots` | see the `robots` module for named directives |
| `with_jsonld` | attach a schema.org document; merged with the library's own |
| `with_article` | mark the page an article — this is what sets `og:type` and emits the `article:*` tags |
| `replace_style_sheets` / `extend_style_sheets` | and the `scripts` equivalents |
**`TemplateData`** — what Handlebars actually sees, assembled from both plus your
own data. Your data is namespaced under `{{app.…}}`; pass `()` for none.
Computed for you: `display_name` (`"{page_name} | {project}"`, the only way to
influence `<title>`), `canonical_url`, `locale`, `social_image_url`,
`copyright_end` (per render, so a long-running server does not keep claiming last
year), `preconnect`, `color_scheme` and `theme_colors`.
Strict mode is on: a field a template asks for and the data does not supply is a
render error, not an empty string. A blank `<title>` is a bug that ships; a
failed render is one that gets fixed.
Helpers available in any template: `join` (comma-joins a list), `pretty_date`
(`January 15, 1990`), `has_key` (whether a map holds a key).
### `ThemeColor`
Browser-chrome colour and supported schemes are one type, because they have to
agree — `color-scheme: light dark` with a single theme colour gives a dark page a
light address bar.
```rust
ThemeColor::light("#fafafa") // color-scheme: light
ThemeColor::dark("#121212") // color-scheme: dark
ThemeColor::light_dark("#fafafa", "#121212") // color-scheme: light dark, two media-scoped tags
```
Use the page **background** per scheme, so the chrome reads as continuous with
the page rather than as an accent stripe above it.
### The theme script
`BaseTemplateData` carries a `theme_fallback` beside its colours, and the library
builds the script from it — there is nothing to construct or pass.
It is inline, blocking and pre-paint, so a page never flashes the wrong theme.
Three states, and the middle one matters:
1. the reader chose a theme → stamp `data-theme` on `<html>`;
2. the reader chose nothing but the OS states a preference → stamp **nothing**,
so your media queries stay live and follow the OS mid-session;
3. neither states anything → stamp `theme_fallback`.
The storage key is always `theme`. What `[data-theme="dark"]` *means* is entirely
your CSS; this library owns only the handshake.
### `SiteEntity`
Who or what the site is, for the JSON-LD entity node. `SiteEntity::person(..)` or
`SiteEntity::organization(..)` — a band is not a person, and search engines
reconcile this claim against the wider web. The `goddtriffin` preset defaults to
`Person`; a project that is a business or a band overrides it with
`with_site_entity`.
### JSON-LD
On the **home page only** — Google requires `WebSite` markup at the domain root —
the library emits a `@graph` containing `WebSite` and your `SiteEntity`, with
`sameAs` populated from `see_also` and `logo` from the social image.
A page's own `with_jsonld` is **merged**, not replaced. Nodes match by `@id`
(falling back to `@type`); a matching node has its properties shallow-merged over
the default, so naming one property does not discard the rest, and a node
matching nothing is appended. Documents are hex-escaped, so a `</script>` inside
a string value cannot break out of the block.
## Pages
`Pages` declares routes and the sitemap together, so they cannot disagree.
```rust
Pages::new()
.static_page(PageTemplateData::new("home", "Home", "/"), ())
.dynamic_page("/blog/{slug}", handler)
.extend_images(["/static/image/social/card.webp"])
.with_last_modified(post.updated_at)
.unlisted() // route it, keep it out of the sitemap
```
There is no `not_found` — the library declares it.
`extend_images`, `with_last_modified` and `unlisted` apply to the page declared
immediately before them.
## Sitemaps
`/sitemap.xml` is **always** a `<sitemapindex>`, naming one or more
`/sitemap-N.xml` url sets — on a three-page site and a three-million-page one
alike. `robots.txt` and any Search Console submission therefore point at one URL
forever; a site that outgrows the protocol's 50,000-URL limit simply gains
another url set inside the index, and nothing outside the server changes.
Chunking respects both the URL count and a serialized-byte budget, because
`sitemap-rs` enforces only the former and image-heavy entries can pass that check
while still exceeding 50 MB.
`<changefreq>` and `<priority>` are not emitted: Google ignores both. `<lastmod>`
is the newest modification time across `html/`, `static/` and the binary —
stable across restarts, moving only when something that determines the output
actually changed. If that value is implausible (an epoch-normalised build, a
skewed clock) the tag is **omitted** and an `error!` is logged, because Google
uses `lastmod` only when it is consistently and verifiably accurate and one bad
pattern discredits the whole file.
Sitemaps are held in memory and served from there. Nothing is written to disk.
## Feeds
A site may declare **one** feed, served in all three formats at fixed paths:
`/rss.xml`, `/atom.xml` and `/feed.json`. `WebServer::feed` is omittable — a
site with no stream serves no feed documents and emits no autodiscovery links.
Calling it without `frontend` fails the boot rather than silently serving
nothing.
```rust
WebServer::from_env()?
.frontend(params)
.feed(Feed {
title: String::from("Blog | Todd Everett Griffin"), // what a reader lists you as
description: String::from("Writing on Rust and WebGPU."),
page_url: String::from("/blog"), // must be a declared page
entries: posts.iter().map(|post| FeedEntry {
path: format!("/blog/{}", post.slug), // also the permanent id
title: post.title.clone(),
summary: post.description.clone(),
content_html: Some(post.content.clone()), // `None` ships a teaser
published: post.published,
modified: post.updated,
tags: post.tags.clone(),
image: Some(post.card.clone()),
}).collect(),
})
.run(shutdown)
```
Entries are **passed in, not derived**: a blog is a `dynamic_page_group`, and a
dynamic page builds its template data per request, so there is nothing to read
at boot. Supply them in any order and in any number — the library sorts
newest-first and keeps 20. Deduplicate however suits your data; the library
reports a repeated `path` but will not silently drop one.
`FeedEntry` is a named-field struct rather than a builder so that shipping a
teaser feed is something you type (`content_html: None`) rather than something
you forget.
**Post content is rewritten, not copied.** A reader renders your entry on
`feedly.com`, so every relative `src` and `href` is cache-busted and then made
absolute, and a bare `#fragment` is resolved against the post rather than
against the reader's own page. Rewriting is confined to real elements, so a post
that merely *shows* markup in a code fence is left alone. `srcset` fails the
boot instead of being half-rewritten.
Everything else is derived from what the site already declares — author,
language, icons, copyright, self and alternate links — so there is nothing to
restate and nothing to drift.
### Caching
Feeds are the most-polled document a site serves, so unlike the other generated
documents they are **not** served `no-store`. Each carries
`Cache-Control: public, max-age=1800`, a strong `ETag` computed at boot, and
`Last-Modified`; a conditional request gets a bodiless `304`.
That only works because the documents are **byte-stable**: `<lastBuildDate>` and
`<updated>` come from the newest entry rather than the clock, and entries
sharing an instant break the tie on path, so a `HashMap` upstream cannot move
the bytes. A redeploy that changed no post re-sends nothing.
### Discovery
Three `rel="alternate"` links go in every page's `<head>`, not only the page the
feed mirrors, so a reader handed any URL on the site finds the stream. They are
ordered RSS, Atom, JSON: a reader that takes the first rather than offering a
choice is far more likely to be RSS-only than Atom-only.
`robots.txt` gains exactly one line — `Sitemap: {base_url}/atom.xml`. Google
accepts an RSS or Atom file as a sitemap and recommends one *alongside* a full
sitemap: the sitemap is the inventory, the feed is the recency signal. Atom is
the one listed because it has a genuine per-entry `<updated>`, so a revised post
looks revised; RSS carries publication dates only. JSON Feed is not a supported
sitemap format.
### What fails the boot, and what only shouts
Refused outright: an entry with no title, summary or rooted path; `srcset` in
content; a `page_url` that is not a declared page; a declared `image` absent
from the manifest.
Logged at `error!` and served anyway: a feed with no entries, a repeated `path`,
a future `published`, a `modified` earlier than its `published`, an image inside
content that the manifest does not know, and any character stripped because XML
1.0 cannot represent it.
## Static assets
Hashing happens at **build time**, never at startup.
```
gen_css → static/stylesheet/main.css
gen_static_assets → derive the icon set from favicon.svg; hash every non-script
asset; write cache-buster.json and the TypeScript module
gen_js → the bundler inlines that module
gen_static_scripts → hash the built JavaScript
server boot → read the manifest. No hashing, no writes.
```
Two phases, because the JavaScript *contents* depend on the manifest while the
JavaScript *files* can only be hashed once they exist. Scripts never need their
own hash — the layout reads that from the manifest.
Both commands are subcommands of **your own server binary**, so no project needs
a shim binary or a line of glue:
```sh
my-server gen-static-assets
my-server gen-static-scripts
my-server # serve
```
They exit before the runtime or error monitoring start. They do still read
`WSB_ENVIRONMENT`, because `main` resolves it before calling in — set it to
`local` in your build stage.
**Everything under `static/` is hashed**, including the icons, because `/static/*`
is served immutable-for-a-year and every byte in it must therefore be
content-addressed. Well-known root routes (`/favicon.ico`, `/robots.txt`, …)
serve the same bytes never-cached, where the URL cannot change.
`cache-buster.json` and `static/script/generated/cache-buster.ts` are **build
outputs — gitignore both.** Add this to every consuming project:
```gitignore
cache-buster.json
static/script/generated/
```
### Icons
A project authors **exactly one** source, and everything else is generated:
- `static/image/favicon/favicon.svg` — preferred. You also get
`<link rel="icon" type="image/svg+xml">` and, if the SVG carries its own
`prefers-color-scheme` rules, a dark-mode-adaptive tab icon.
- `static/image/favicon/favicon-512.png` — exactly **512×512**, for art that
cannot be vectorised. A photograph is the obvious case: autotracing a face
yields either a posterised caricature or a multi-megabyte pile of paths. The
size is in the filename so the requirement is hard to miss. No SVG link is
emitted in this case, because linking a file that does not exist is worse than
not linking one.
`favicon.ico` (32), `apple-touch-icon.png` (180), `icon-192.png` and
`icon-512.png` derive from whichever you provide. Every derived size is a
*downscale* from 512, which is faithful; upscaling never is, so a source smaller
than 512 is refused.
**The icon directory is a closed set.** It may contain only the source and the
four derived files:
```
static/image/favicon/
apple-touch-icon.png <- generated
icon-192.png <- generated
icon-512.png <- generated
```
Boot errors: neither source present; **both** present (two sources drift the
moment one is updated); a PNG that is not exactly 512×512; a derived icon that
is absent or the wrong size; or **any other file in the directory**. A stale
`favicon-16.png` from a previous design is invisible until the wrong picture
turns up in a browser tab, so it fails the boot instead.
A file already present is left alone, so a hand-tuned 32×32 `.ico` — the one
size where a naive downscale of a detailed mark really does look muddy — still
wins.
An SVG **must not rely on system fonts**; convert text to paths.
## Analytics and error monitoring
Both vendors' origins are on blocklists, and a blocked request is a visitor you
never counted or an error you never saw. This library serves both from your own
origin, which Plausible themselves recommend — they put the cost of not doing it
at 5–25% of visitors.
Routes are derived from the project name, never configured: Plausible advises
against their documented default paths (blocklists target them) and against
words like "analytics" or "stats". A per-project name also means no single filter
rule can take out every one of your sites at once. The shape — `name-hash.js` —
is what every bundler already emits.
```
GET /script/{project}-{hash}.js → the Plausible script
POST /api/v1/{project}-{hash} → events, with the real visitor IP forwarded
GET /script/{project}-{hash2}.js → the Sentry browser loader
POST /api/v1/{project}-{hash2} → Sentry envelopes
```
Both are **byte relays**. Nothing is parsed or re-reported — which matters most
for Sentry: a server that interpreted a client error and re-raised it through its
own SDK would file a browser problem as a server one, with the wrong stack. The
tunnel's destination comes from the configured DSN, not the envelope, so it
cannot be used as an open relay.
`X-Forwarded-For` carries the true client IP (resolved through the usual proxy
headers); without it Plausible's bot filter rejects proxied events outright.
Server-side and browser-side Sentry are **complementary, not alternatives**: the
Rust SDK catches panics and handler errors, the browser SDK catches JavaScript
exceptions. Use two Sentry projects — Sentry recommends one per language and per
deployable, and it isolates rate limits.
| `WSB_ANALYTICS_ID` | the Plausible script id, e.g. `pa-1qi0TQ…` — Site Settings → Site Installation |
| `WSB_SENTRY_SERVER_DSN` | the Rust server's DSN |
| `WSB_SENTRY_BROWSER_DSN` | the browser's DSN — required for a frontend |
All three are required in **every** environment, local included. A DSN exercised
only in production is a DSN nobody has proved works; point local runs at
development projects.
⚠️ Plausible's script disables itself on `localhost`, so a local pageview will
not register no matter what you configure. To exercise it end to end, add your
development domain as its own Plausible site and map it to `127.0.0.1` in
`/etc/hosts`.
## Observability
`bootstrap` owns this. It resolves `WSB_ENVIRONMENT`, builds observability from
`WSB_SENTRY_SERVER_DSN`, and initialises it on the main thread *before* the
runtime — so the Sentry hub reaches the runtime's workers — then drops the guard
after the drain so errors raised during shutdown are still flushed. No project
writes any of that.
`bootstrap!` also owns the build-tool subcommands, checking them before it
touches the environment at all, so `gen-static-assets` needs no configuration.
**It is a macro, not a function**, and that is load-bearing. Sentry attributes
issues to a *release*, and the release has to name your application. Resolving it
inside this library — which is what `sentry::release_name!()` would do — makes
every project report `webserver-base@x.y.z`, so Sentry cannot tell one site's
deploys from another's and regression detection stops working. The macro expands
`CARGO_PKG_NAME` and `CARGO_PKG_VERSION` at *your* call site, so the release is
correct with nothing to configure.
## Logging
Format follows the environment, because a human reads one and a machine reads
the other:
```
local INFO request{method=GET uri=/robots.txt version=HTTP/1.1}:
finished processing request latency=0 ms status=200
production {"timestamp":"…","level":"INFO","message":"finished processing request",
"span":{"name":"request","method":"GET","uri":"/robots.txt"},
"status":200,"latency":"0 ms","target":"tower_http::trace::on_response"}
```
Production JSON is `flatten_event`ed with the current span attached, so `method`,
`uri`, `status` and `latency` are queryable fields rather than a string to grep.
ANSI colour is off there too — a log driver stores escape codes verbatim and
nothing downstream strips them.
Note the request span is created at **INFO**. `tower-http` defaults it to DEBUG,
which under an INFO filter means the span never exists and every request logs a
status and a latency with no way to tell which route it was.
One line at boot names the running build — the one thing no other log can tell
you when an incident starts:
```
INFO starting release=my-project@1.2.3 environment=production log_filter=info
```
`RUST_LOG` is honoured and stays unprefixed, being an ecosystem convention;
`with_log_filter` sets the fallback.
### Cache tiers are named at boot
Every served resource is logged once at startup with the tier it falls under and
the URL it is actually reachable at:
```
INFO immutable 1y /static/stylesheet/main.css -> /static/stylesheet/main.a1b2c3d4.css
INFO uncached /robots.txt
INFO cached 30m /atom.xml (etag "4f2a9c1e")
```
Deliberately at boot rather than per request. A request for an immutable asset
arriving at the origin is indistinguishable from a first-time visitor, a bot, a
hard refresh or an evicted entry — only one of those is a fault, so a
cache-miss log could not tell you anything. What *is* decidable, and decided
exactly once, is whether each tier was wired up at all: if the immutable lines
are missing, caching is broken, and you can see it in the first screen of a
deploy's logs. To check the tier is being honoured beyond your origin, read the
response headers at the edge rather than inferring it from volume here.
**Severity policy.** `sentry-tracing` maps `error!` to a Sentry event and
`warn!` to a mere breadcrumb, so a warning with no subsequent error is never
seen. Therefore: a boot failure for misconfiguration that makes the site wrong;
`error!` for "boots, but a feature is silently degraded"; `warn!`/`info!` only
for things nobody needs to act on.
## Telegram
`telegram` is an outbound Bot API notifier, send-only on purpose: no polling, no
webhooks, no update handling, because no consuming project receives anything. It
replaces the hand-rolled `sendMessage` calls that had been copied between
projects, each missing a different subset of the hard parts.
```rust
let settings: TelegramSettings = TelegramSettings::builder("123456789:AA...").build()?;
let telegram: ReqwestTelegram = ReqwestTelegram::new(settings, None)?;
telegram.send(
ChatId::Id(1234),
Message::builder()
.text("🎨 ")
.bold("New pattern")
.text("\nInput: ")
.code(untrusted_filename) // no escaping, ever
.build(),
);
```
| `Telegram` | the trait to depend on — `send`, and `send_text` for the unformatted case |
| `ReqwestTelegram` | the real implementation; `new(settings, None)` builds its own client. Adds `flush` and `queued`, which are inherent to it, not on the trait |
| `MockTelegram` | the test double: `sent`, `sent_to`, `texts`, `len`, `clear` |
| `Message` / `MessageBuilder` | `Message::text` for the plain case, `Message::builder` for formatting |
| `Style`, `Entity`, `EntityKind` | the formatting primitives the builder emits |
| `Media`, `FileSource` | photo, video and document sends, with a caption |
| `ChatId` | `Id(i64)` or `Username(String)` |
| `TelegramSettings` | `builder(token)`, then queue capacity, chunk cap, timeouts |
| `SendOptions` | per-send overrides: `disable_notification`, `protect_content`, `disable_link_preview`, `message_thread_id`, `reply_to_message_id` |
| `TelegramError`, `BotToken` | errors, and the token type that redacts itself |
**`send` returns immediately.** It queues; a background task chunks, paces,
retries and delivers. That is why it takes `&self` and cannot fail — the
failure surfaces in the logs, not the call site.
**Formatting emits entities, not `parse_mode` markup.** Telegram's own docs
describe entities as what a Markdown or HTML parser is converted *into*, so
nothing is lost by skipping that step — and because no markup is ever embedded
in the text, **interpolated values never need escaping**. This is the whole
reason the builder exists: a filename containing `*` or `_` cannot corrupt a
message or inject formatting.
Chunking counts **UTF-16 code units**, because that is what Telegram's 4096
(text) and 1024 (caption) limits count. Entities are clamped and rebased onto
each piece. A message that would exceed the configured chunk cap is truncated
and the truncation is logged at `error!`, because a silently shortened
notification is worse than a loud one.
`flush(timeout)` waits for the queue to drain so the last notifications —
usually the ones that matter — are not lost. It returns `false` and logs at
`error!` if the queue did not empty in time.
**Call it from [`AppShutdown`](#appshutdown--draining-your-own-state), not by
hand.** Hold the notifier in your app state and flush it there: `send` returns
immediately, so whatever is still queued when the process exits is gone, and
that typically includes the notification about whatever caused the exit. Doing
it in the trait is what makes forgetting a compile error rather than a silence
you discover in production.
Bot tokens redact themselves structurally in `Debug`, errors and logs, so a
token cannot reach Sentry by being interpolated into a message.
## Environment
Every variable this crate reads is prefixed `WSB_` so it cannot collide with
yours. `Environment::from_env` reads `WSB_ENVIRONMENT` (`local` or `production`).
The `env` module offers `required`, `optional` and `parse_or` for your own.
---
# The TypeScript library
Published to JSR as [`@todd/webserver-base`](https://jsr.io/@todd/webserver-base).
```jsonc
{ "imports": { "@todd/webserver-base": "jsr:@todd/webserver-base@^0.1.1" } }
```
## `@todd/webserver-base/bundle`
The esbuild wrapper every project's `gen_js` target runs. It discovers every
top-level `static/script/*.ts` as an entry point (subdirectories are pulled in
transitively, not bundled separately) and emits minified ES modules with source
maps into `bin/static/script/`.
```sh
deno run --allow-read --allow-write --allow-env --allow-net --allow-run \
@todd/webserver-base/bundle
```
## `@todd/webserver-base/logger`
`ILogger` with three implementations: a real console logger, a no-op, and a mock
that records calls for assertions.
## `@todd/webserver-base/free-port`
Picks a stable per-project development port, so two of your servers running at
once do not collide and the port does not move between runs.
## The generated cache-buster module
Written by `gen_static_assets` to `static/script/generated/cache-buster.ts` and
inlined by the bundler, so resolving an asset costs the browser nothing:
```ts
import { CACHE_BUSTER, asset } from "./generated/cache-buster.ts";
img.src = asset("static/image/assets/flag.png");
// ^ a typo here is a compile error, not a silent `undefined`
```
`CacheBustedPath` is the union of every known asset path. That type is the point:
the old untyped JSON map returned `undefined` for a mistyped key and produced a
broken image with no error anywhere.
---
## Conventions a consuming project inherits
```
html/
pages/ at least one .hbs, and 404.hbs — both required
partials/ your chrome
layouts/ optional; `base` is reserved
static/
image/favicon/favicon.svg (or favicon-512.png at 512x512) — the rest is generated
image/ script/ stylesheet/ scss/
cache-buster.json build output — gitignored
static/script/generated/ build output — gitignored
```
Where that tree lives is `root_dir`, defaulting to the working directory — the
build pipeline itself always runs in the working directory.
Fixed and not configurable: the `html/` and `static/` directory names, the
`/api/v1` prefix, the `theme` storage key, the sitemap route shape, the derived
proxy paths, `<meta charset="utf-8">`, the viewport string, and the entire
`<head>`.
---
# Working on this repo
## Skills
| [`contribution-guide`](.claude/skills/contribution-guide/SKILL.md) | writing, reviewing, refactoring, testing or documenting **any** code here — invoke it at the start of every change, however small |
| [`rollout`](.claude/skills/rollout/SKILL.md) | shipping a finished change: version bump, E2E smoke test, Docker push, commit to main, tag, GitHub release, crates.io and JSR publishes |
Rules live in the skills, not here. Invoke `contribution-guide` before any
change to this repo.