How it works
Four pieces, none of them inside Kobweb.
1. An interceptor, not a route
A KobwebServerPlugin gets the raw Ktor Application, so it can register anything. Registering a route is not enough: in the static layout an exported page owns the same path as a constant route, both are equally specific, and plugins are configured after Kobweb's own routing — so the page wins. An interceptor on the earliest pipeline phase wins in both layouts.
2. A renderer in its own process
The kobwebServerPlugin configuration is not transitive, so exactly one jar reaches the server — Playwright cannot ride along. The renderer is a sidecar reached over HTTP, which also makes the seam honest: swapping it for a Node process took no change to the interface.
3. A bypass header
The renderer produces the page by asking this same server for it. Without a marker to stand aside for, the interceptor would catch the renderer's own fetch and ask the renderer again, forever. The same recursion exists in threads: waiting for the renderer on a Ktor event-loop thread starves the loop the renderer needs, and eight concurrent requests all took exactly the client timeout until that was moved off.
4. Baking the CSSOM into the markup
Compose HTML creates empty style elements and fills them through the CSSOM, so a document saved as-is carries the tags and none of the rules. Each sheet's own rules are written back into its element before serialising — the same trick kobweb export uses, with one fix: iterate a snapshot of document.styleSheets, not the live collection.
visitor -> Ktor interceptor -> cache -> sidecar -> headless browser -> this same server
What each piece costs
- A render: about 0.63 s, measured on a laptop against an unminified development bundle.
- A cache hit: about 1.8 ms.
- A renderer worker: roughly 342 MiB, on top of about 115 MiB fixed. Pool size is bounded by memory, not by cores.