feat(surf): branded HTML error pages for browser navigations

A browser navigation (Sec-Fetch-Mode navigate, or GET/HEAD without it; no
X-Requested-With; Accept naming text/html above every JSON type; not /api,
/_<name> or the admin prefix) that hits a framework-generated 404, 405 or
500 gets a self-contained SummerCMS page: unmatched routes and wrong
methods at the ServeMux, failed Where/WhereIn constraints, overlap-family
misses, the unpublished uploads route and recovered panics. Every other
request keeps the exact stdlib text, wire's opaque JSON 500 or the bare
raw-group 500.

Details (method, path, X-Request-Id) only with app.debug or outside
production. Applications replace the page with a published
surf.ErrorPageRenderer; http.error_pages.enabled false turns it off.
This commit is contained in:
2026-10-20 03:12:25 +02:00
parent 7c2b7fcb79
commit b1236cbfd4
14 changed files with 813 additions and 28 deletions

View File

@@ -38,14 +38,16 @@ surf wraps every non-raw route in the same layers. From the outside in:
1. Optional locale-prefix rewrite, when a `surf.LocaleResolver` is published: an enabled locale as the first path segment is stripped so later layers and routing see the unprefixed path.
2. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
3. The route's CORS profile, when it or its group names `cors:<profile>`. It sits here whatever its position in the middleware list, so refusals and recovered panics carry its headers too.
4. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
4. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), or the HTML error page with status 500 when the request is a browser navigation (see [Error pages](../services/routing.md#error-pages)). Because the response is buffered until the handler returns, the client never receives half a body.
5. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
6. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
7. The route's middleware, in the order you listed them: group middleware first, then the route's own (`cors:<profile>` excepted, see step 3).
8. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
9. Your handler.
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500, or the HTML error page for a browser navigation.
A request that no route matches, or that uses a method its path does not accept, is answered by the ServeMux and negotiated the same way: a browser navigation gets the HTML error page with status 404 or 405, every other caller the stdlib text.
## Request context values