@kailua-packages/headless-sdk
v0.3.0
Published
TypeScript-SDK fuer die Kailua Headless-CMS REST-API (apps/headless).
Readme
@kailua-packages/headless-sdk
Typsicheres TypeScript-SDK fuer die Kailua Headless-CMS REST-API (apps/headless).
Spiegelt die API unter /headless/api/v1 mit Bearer-Token-Authentifizierung
(Authorization: Bearer hcms_live_...).
Installation
Workspace-Paket – im Monorepo via Workspace-Referenz:
{
"dependencies": {
"@kailua-packages/headless-sdk": "*"
}
}Schnellstart
import { createClient } from "@kailua-packages/headless-sdk"
const hcms = createClient({
baseUrl: "https://acme.kailua-packages.ch/headless",
token: process.env.HCMS_TOKEN!,
})
// Collections + Schema laden
const collections = await hcms.collections.list()
// Eintraege einer Collection abfragen (mit Generic fuer data)
const posts = await hcms.collection("blog").find<{ title: string; body: string }>({
status: "published",
locale: "de",
sort: "title",
order: "asc",
page: 1,
limit: 20,
populate: ["author"],
fields: ["title", "body"],
filters: {
featured: { op: "eq", value: true },
price: { op: "gte", value: 10 },
tag: { op: "in", value: ["news", "release"] },
},
})
console.log(posts.data, posts.meta) // { data: Entry[], meta: { page, limit, total } }API-Oberfläche
| Methode | Beschreibung |
| --- | --- |
| collections.list() | Alle API-fähigen Collections inkl. JSON-Schema |
| collection(slug).find<T>(query?) | Eintragsliste mit Filtern/Sortierung/Pagination → { data, meta } |
| collection(slug).findOne<T>(id, query?) | Einzelner Eintrag (populate/fields) |
| collection(slug).create<T>(input) | Eintrag anlegen (write-Scope) |
| collection(slug).update<T>(id, input) | Eintrag aktualisieren (write-Scope) |
| collection(slug).delete(id) | Eintrag soft-loeschen (write-Scope) |
| media.list(query?) | Medien-Assets → { data, meta } |
| pages.list(opts?) | Page-Liste (ohne blocks) → { data, meta } |
| pages.byPath(path, opts?) | Eine Page inkl. Block-Baum per Pfad → { page, collectionTags } \| null |
| menus.list() | Alle Menues (key, name) |
| menus.get(key) | Ein Menue inkl. verschachtelter Items → MenuData \| null |
| theme.get() | Default-Theme inkl. Design-Tokens |
| blocks.list() | Block-Palette (Built-ins ∪ DB-Definitionen) |
| search<T>(q, opts?) | Volltextsuche ueber veroeffentlichte Eintraege |
| openapi() | OpenAPI-Dokument der API |
Query-Builder
interface QueryParams {
status?: string
locale?: string
environment?: string
sort?: string
order?: "asc" | "desc"
page?: number
limit?: number
populate?: string[]
fields?: string[]
filters?: Record<string, { op: FilterOperator; value: unknown }>
}Filter-Operatoren: eq, neq, gt, gte, lt, lte, contains, startsWith, in.
Abbildung auf die API-Query-Syntax:
{ title: { op: "eq", value: "Foo" } }→?title=Foo{ price: { op: "gte", value: 10 } }→?price[gte]=10{ tag: { op: "in", value: ["a", "b"] } }→?tag[in]=a,b
Filter — vollstaendige Beispiele (alle Operatoren)
const results = await hcms.collection("shop").find({
filters: {
// Gleichheit / Ungleichheit
featured: { op: "eq", value: true }, // ?featured=true
category: { op: "neq", value: "intern" }, // ?category[neq]=intern
// Numerische Vergleiche (auch fuer date/datetime-Felder als ISO-String)
price: { op: "gte", value: 10 }, // ?price[gte]=10
stock: { op: "gt", value: 0 }, // ?stock[gt]=0
weight: { op: "lt", value: 5 }, // ?weight[lt]=5
rating: { op: "lte", value: 4.5 }, // ?rating[lte]=4.5
// String-Operatoren
title: { op: "contains", value: "CMS" }, // ?title[contains]=CMS
slug: { op: "startsWith", value: "blog-" }, // ?slug[startsWith]=blog-
// Listen-Match
tag: { op: "in", value: ["news", "release"] }, // ?tag[in]=news,release
},
})Alle Filter im filters-Objekt werden UND-verknuepft. Fuer ODER-Verknuepfungen
und verschachtelte Gruppen bietet die API zusaetzlich einen JSON-Filter
(?filter={"$or":[...]} mit $and/$or/$eq/$ne/$gt/$gte/$lt/$lte/$contains/
$startsWith/$in/$notIn/$null, max. Tiefe 3) — siehe
apps/headless/FRONTEND_INTEGRATION.md, Kapitel 9. Eine typisierte
filter-Option im SDK ist geplant; bis dahin den JSON-Filter bei Bedarf per
eigenem fetch gegen die API nutzen.
populate — Relationen & Media einbetten
populate loest Relationsfelder serverseitig auf. Punktpfade laden
verschachtelt (maximal Tiefe 3); Media-Felder (image, file, gallery) im
Pfad werden als vollstaendige Media-Objekte inkl. variants eingebettet:
const posts = await hcms.collection("blog").find<{
title: string
// nach populate: eingebetteter Author-Entry statt ID
author: { id: string; data: { name: string; avatar: unknown } }
}>({
populate: ["author", "author.avatar"], // → ?populate=author,author.avatar
limit: 10,
})
// posts.data[0].data.author.data.avatar →
// { id, url, alt, width, height, focalX, focalY, variants: [{ label: "thumb", url, ... }] }Pages, Menus & Theme
The Baukasten (page builder) delivery endpoints let a frontend render whole
pages, navigation menus, the design theme and the available block palette —
without hand-rolling fetch calls.
Pages
// List pages (lightweight — no `blocks`)
const pages = await hcms.pages.list({ locale: "de" })
// → { data: PageSummary[], meta: { page, limit, total } }
// Load one page including its block tree, by path
const result = await hcms.pages.byPath("/about", { locale: "de" })
if (result) {
const { page, collectionTags } = result
console.log(page.title, page.blocks)
} else {
// 404 → byPath returns null (no throw)
}byPath returns null when the API responds with 404 (page not found), so
you can branch on null instead of catching an error.
The block tree is recursive. Each PageBlock carries its raw field values in
data; blocks with slots expose their children under children (slot key →
child blocks):
function render(blocks: PageBlock[]) {
for (const block of blocks) {
// block.type, block.data, block.resolved?.<field>
for (const [slot, kids] of Object.entries(block.children ?? {})) {
render(kids) // recurse into named slots
}
}
}Data resolution (resolve)
Server-side data resolution is on by default: data-driven blocks get their
referenced entries attached additively under block.resolved.<field> (either a
single entry, an entries list, or a per-field error). data is never
mutated, so this stays backwards-compatible.
Pass resolve: false to get the raw blocks (sends ?resolve=none) — useful if
you resolve references yourself or want a maximally cacheable response:
const raw = await hcms.pages.byPath("/about", { resolve: false })
// raw.page.blocks[i].resolved is absentWhen resolution is active and the page references collections, the API attaches
the referenced collection cache-tags. The SDK surfaces them as
result.collectionTags (hcms:<org>:collection:<slug>), which you can feed
into your own tag-based fetch cache for fine-grained invalidation. For pages
without data blocks (or with resolve: false) the array is empty.
Draft mode (previewToken)
To render a draft of a specific page (e.g. a preview link from the editor),
pass its previewToken. It is forwarded as ?previewToken= and does not
replace the Bearer token — it only unlocks the draft read for exactly that one
page:
const preview = await hcms.pages.byPath("/about", {
locale: "de",
previewToken: params.get("previewToken") ?? undefined,
})Menus
// All menus (key + name only)
const menus = await hcms.menus.list() // MenuSummary[]
// One menu including its nested, visible items
const main = await hcms.menus.get("main")
if (main) {
// main.items is a tree; each item has children: MenuItem[]
walk(main.items)
}menus.get returns null on 404. Only visible items are returned, sorted by
order.
Theme
const theme = await hcms.theme.get()
// theme.tokens is Record<group, Record<token, value>>, e.g.
// { colors: { primary: "#0af" }, spacing: { md: "16px" } }
const primary = theme.tokens.colors?.primaryWhen the organization has no default theme, id/name are null,
isDefault is false and tokens is an empty object.
Blocks
const palette = await hcms.blocks.list() // BlockDefinitionInfo[]
// { slug, name, description, icon, category, source, allowsChildren,
// slots, fields }The palette merges built-in blocks with DB-defined blocks (DB wins on slug).
description is only set for DB definitions; built-ins report null.
Fehler-Handling
Jede non-2xx-Antwort wirft einen HcmsApiError mit status (HTTP-Status),
code (API-Fehlercode) und message:
import { HcmsApiError } from "@kailua-packages/headless-sdk"
try {
await hcms.collection("blog").find()
} catch (err) {
if (err instanceof HcmsApiError) {
console.error(err.status, err.code, err.message) // z.B. 403 FORBIDDEN ...
}
}Fehlercodes der API: UNAUTHORIZED, TOKEN_INACTIVE, TOKEN_EXPIRED (alle
401), FORBIDDEN (403, fehlender Scope oder Collection-Scope), NOT_FOUND
(404), BAD_REQUEST (400) und RATE_LIMIT_EXCEEDED (429).
Rate-Limits
Ist das Token-Rate-Limit ueberschritten, wirft das SDK einen HcmsApiError
mit status: 429 und code: "RATE_LIMIT_EXCEEDED". Die API sendet dazu einen
Retry-After-Header (Sekunden). Beispiel fuer einen einfachen Retry:
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
if (err instanceof HcmsApiError && err.status === 429) {
// Konservativ warten und einmal wiederholen. (Ein retryAfterSeconds-Feld
// direkt auf HcmsApiError ist geplant.)
await new Promise((r) => setTimeout(r, 5_000))
return fn()
}
throw err
}
}
const posts = await withRetry(() => hcms.collection("blog").find())OpenAPI & Typgenerierung
hcms.openapi() liefert die OpenAPI-3.1-Spec der Organisation — nutzbar fuer
Codegeneratoren wie openapi-typescript (siehe
apps/headless/FRONTEND_INTEGRATION.md, Kapitel 8):
const spec = await hcms.openapi() // entspricht GET /openapi.jsonGeplant (Phase 4): hcms types generate in der CLI
(@kailua-packages/headless-cli) erzeugt direkt aus dem Collection-Schema eine
Typendatei (cms/types.generated.ts) mit Interfaces pro Collection —
inklusive Literal-Unions fuer select-Felder und discriminated unions fuer
flexible_content. Der Client wird dann per Generic typisiert
(createClient<HcmsCollectionMap>), bleibt aber ohne Typparameter
rueckwaertskompatibel.
Eigenes fetch
createClient({ baseUrl, token, fetch }) akzeptiert ein optionales fetch
(z.B. fuer Tests oder Node < 18). Standardmaessig wird das globale fetch
verwendet.
Tests
npm run test --workspace=@kailua-packages/headless-sdk