Skip to content

Implementing Keel

Pack adapter

PageModule, bootstrap, manifest.json, .feb layout, and writing a RouterAdapter.

A pack is a UI that implements page ids. It never owns URL patterns. Svelte was the first implementation of this contract; seven adapters now ship — see Framework adapters.

PageModule

Every page module exports:

export function mount(host: Element, ctx: PageContext<T>): void | Promise<void>
export function unmount(): void | Promise<void>
export function update?(ctx: PageContext<T>): void | Promise<void>

update runs when a visit keeps the same page id and preserveState is set (partial reloads, validation 422s). Framework adapters generate this wrapper so authors write ordinary files.

Bootstrap and hydration

The shell loads bootstrap.js as a module. Bootstrap reads #__keel_seed, mounts the entry, and starts the router. The initial applySeed must not steal focus or announce — that is for subsequent client navigations.

Client visits go to the page URL with X-Keel-Visit: true. Prefetch dedupes in-flight GETs, caps the seed cache, and must ignore partial responses.

manifest.json and .feb

A .feb is a zip whose root is manifest.json plus the files it names. There is no nested pack directory. Format keel/1:

{
  "format": "keel/1",
  "id": "harbor",
  "version": "0.1.0",
  "framework": "svelte",
  "host": "#__keel_root",
  "pages": {
    "harbor.home": {
      "module": "pages/harbor.home.js",
      "css": [
        "assets/styles-4f3c2b1a.css"
      ]
    }
  },
  "notFound": "pages/harbor.notFound.js"
}

keel-pack zips a Vite dist into that archive. The Vite plugin (keelPack) discovers pages, writes the manifest, and calls a packager.

Page entry fields:

Field Required Meaning
module yes Page module path, relative to the archive root
css no Stylesheet paths the page needs
head no Compiled head markup with {{…}} placeholders

Layouts are not separate manifest entries. Adapters inline the page’s layout chain, root-first, into the emitted entry module. The manifest carries the notFound module alongside pages. The host substitutes the head placeholders and renders it on the document GET. See Document head and SEO.

RouterAdapter

RouterAdapter maps files to page ids — never URL params:

interface RouterAdapter {
  name: string
  discover(pagesDir: string): DiscoveredPage[]
  entrySource(page: DiscoveredPage): string
}

routerFor(name) resolves the built-in adapter for framework: svelte is the default; react, vue, solid, preact, lit, and angular ship as codegen adapters. An unknown name throws with the supported list. Pass router to use a custom adapter — it takes precedence over the registry. New frameworks register an adapter factory; vite.ts does not change.

Each non-Svelte adapter compiles a neutral +head.html instead of +head.svelte, and uses +page.ts for the id override except Lit and Angular, where the page itself is +page.ts, so the override is +page.id.ts.

Packager and manifest hooks

keelPack({
  id: "app",
  version: "1.0.0",
  framework: "svelte",
  manifest: (base) => ({ ...base, /* host extension fields */ }),
  packager: (opts) => { /* directory, tarball, CDN upload; default is packFeb */ },
})

packFeb remains the default packager. Point contract at typegen’s JSON (keel/1 { pages }) so unknown ids fail the build; a .ts Pages interface is still accepted as a fallback.

To start a pack from a running host:

pnpm exec keel-scaffold <origin> ./pack

See Typegen.

See Packs and manifests for the author-facing description and Host adapter for how a host loads and serves the archive.