Skip to content

Implementing Keel

Host adapter

What a backend must implement to be a Keel host. The Ktor plugin is the reference.

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 / @KeelAction annotations) to emit the pack contract.
  • Serve GET /__keel/schema as that JSON from the live registries. keel-scaffold fetches it. The Ktor adapter does this in KeelEngine.

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. Write seed.head into <head>. Emit <link rel="modulepreload"> for bootstrap and seed.entry. Stylesheet links must carry data-keel-css so the client can adopt them.
  • X-Keel-Visit: true: JSON seed. Apply only / except to top-level data keys and set X-Keel-Partial to 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-Match with 304.
  • Cache-Control: public, max-age=31536000, immutable for hashed chunks (chunks/[name]-[hash].js) and assets/.
  • 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.