webserver-base 0.2.4

A Rust library which contains shared logic for all of my webserver projects.
Documentation

webserver-base

Crates.io JSR

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

[dependencies]
webserver-base = { version = "0.2", features = ["pages", "observability", "preset"] }

Nothing is enabled by default.

feature what it gives you
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

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 and frontend 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.

method effect
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
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:

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:

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.

field why it is required
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:

requirement why
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 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:

{{#*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:

method effect
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.

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.

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.

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:

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:

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/
  favicon.svg  |  favicon-512.png     <- exactly one of these
  favicon.ico                          <- generated
  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.

variable what it is
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_evented 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.

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(),
);
type what it is for
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, 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.

{ "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/.

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:

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

skill use it when
contribution-guide writing, reviewing, refactoring, testing or documenting any code here — invoke it at the start of every change, however small
rollout 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.