A Keel host owns URLs, page ids, payload types, actions, and which pack to
mount. It is not required to be JVM. The Ktor adapter
(KeelEngine, HtmlDocument) is the reference implementation of this
checklist.
Page registry
- Store
(id, path, serializer, methods)with path parsed as the Keel grammar. - Resolve incoming paths by specificity, not registration order.
- Default methods to
{ GET }. Bind only declared methods. - Typegen walks the same registry (or
@KeelType/@KeelActionannotations) to emit the pack contract. - Serve
GET /__keel/schemaas that JSON from the live registries.keel-scaffoldfetches it. The Ktor adapter does this inKeelEngine.
Seed construction
Build a seed for every document and visit: page id, absolute path, params,
encoded payload, serving pack { id, version }, entry URL, css URLs, host
selector, optional head / shared / redirect.
Document vs visit
- No
X-Keel-Visit: HTML shell. Writeseed.headinto<head>. Emit<link rel="modulepreload">for bootstrap andseed.entry. Stylesheet links must carrydata-keel-cssso the client can adopt them. X-Keel-Visit: true: JSON seed. Applyonly/exceptto top-leveldatakeys and setX-Keel-Partialto the retained list.
Actions
POST /__keel/action/{id}. Decode JSON with the action’s input serializer,
run (In) -> Out, encode { "data": out }. Validation is 422
{ "errors": … }. If you bind context with a thread-local, propagate it
across coroutine / async hops (ThreadLocal.asContextElement() on JVM).
Pack loading and assets
Load a .feb (zip of manifest.json + modules), a classpath resource, or an
exploded directory. Serve files at /__keel/pack/{bundleId}/….
- Strong
ETag(CRC for zip entries, size+mtime for directories). - Honour
If-None-Matchwith 304. Cache-Control: public, max-age=31536000, immutablefor hashed chunks (chunks/[name]-[hash].js) andassets/.- Otherwise
public, max-age=0, must-revalidate.
Keep the zip central directory open for the bundle’s lifetime; do not reopen it on every request.
Call-site pack
The host opens packs up front and passes one into render. respondPage(pack, id, data) is canonical; route.keel(pack) scopes a pack to a route subtree
so a bundle-less respondPage resolves it. With no scoped route, a
bundle-less render resolves only when exactly one pack is configured in
total; otherwise it throws (MissingPackException / AmbiguousPackException).
The serving pack’s { id, version } goes into the seed and the
X-Keel-Theme / X-Keel-Version response headers; its build hash goes out
as X-Keel-Build. Never select a pack from a request header. Call-site policy
options are on
Choosing a pack.
Ktor config (KeelConfig)
What the reference adapter exposes inside keel { }. A non-Ktor host applies
the same behaviors however it likes.
| Knob | Default | Effect |
|---|---|---|
bundle |
null |
The pack the pages DSL renders from; required when pages are registered and bundles is empty |
bundles |
empty | Additional installed packs for route.keel(pack) subtrees |
packUrlPrefix |
/__keel/pack |
Asset URL prefix /__keel/pack/{bundleId}/… |
bootstrap |
bootstrap.js |
Module entry the shell loads, resolved from the serving pack |
title |
"Keel" |
Shell title when the resolved head has no title |
shared |
null |
SharedProvider that fills seed.shared per request |
notFoundPageId |
null |
Page id rendered for unmatched paths |
csrf |
null |
Custom CsrfPolicy; unset means SameOriginCsrfPolicy(csrfAllowedOrigins) |
csrfAllowedOrigins |
empty | Extra origins the default CSRF policy accepts |
csp |
null |
Opt-in document CSP; see Document head and SEO |
watchPacks |
false |
Poll file/directory packs and swap on change |
packWatchIntervalMs |
500 |
Watcher poll interval (minimum 50 ms); see Pack hot reload |
CSRF
Apply the
protocol CSRF baseline to
every write (page POST and actions). Expose a pluggable policy so a host can
allowlist extra origins. Set session cookies SameSite=Lax.