// Thin client for the CMS HTTP API (docs/onpage-editor.md ยง3-4). Same origin as // the page, so cookies flow; a bearer token from the backoffice login is added // when present. Every call is recorded so the dev panel can show it. import type { EditResult, Op } from '@k6n/cms-editor' import { AuthManager } from './auth' export interface CmsNode { id: string dataType: string data: Record tags?: string[] version?: number } export interface CmsEdge { id: string from: string to: string type?: string handle?: string sort: number } export interface NodeEdges { from: CmsEdge[] into: CmsEdge[] } export interface TypeInfo { type: string [k: string]: unknown } export interface Blueprint { id: string name?: string description?: string [k: string]: unknown } export type JSONSchema = Record export interface RequestLog { n: number method: string url: string status: number ms: number request?: unknown response?: unknown } export class ApiError extends Error { constructor(message: string, readonly status: number) { super(message) this.name = 'ApiError' } } export interface ApiOptions { base?: string fetch?: typeof fetch /** Auth: tokens, refresh and the sign-in flow. Default: a fresh AuthManager. */ auth?: AuthManager onLog?: (e: RequestLog) => void } export class Api { private n = 0 private schemas = new Map>() private typesP?: Promise private blueprintsP?: Promise readonly doFetch: typeof fetch readonly auth: AuthManager constructor(readonly opts: ApiOptions = {}) { this.auth = opts.auth ?? new AuthManager({ fetch: opts.fetch }) const raw = opts.fetch ?? ((...a: Parameters) => fetch(...a)) // Every request: fresh token in, one transparent refresh+retry on 401, sign-out if that fails too. this.doFetch = async (input, init) => { await this.auth.ensureFresh() const send = () => raw(input, { credentials: 'same-origin', ...init, headers: { ...(init?.headers as Record), ...this.auth.headers() } }) let resp = await send() if (resp.status === 401 && (await this.auth.refresh())) resp = await send() if (resp.status === 401) this.auth.markSignedOut() return resp } } headers(): HeadersInit { return this.auth.headers() } async request(method: string, path: string, body?: unknown): Promise { const t0 = performance.now() const url = `${this.opts.base ?? ''}${path}` let status = 0 let json: unknown try { const resp = await this.doFetch(url, { method, headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, }) status = resp.status const text = await resp.text() try { json = text ? JSON.parse(text) : undefined } catch { json = text } if (!resp.ok) throw new ApiError((json as any)?.error ?? resp.statusText ?? `HTTP ${status}`, status) return json as T } finally { this.opts.onLog?.({ n: ++this.n, method, url, status, ms: Math.round(performance.now() - t0), request: body, response: json }) } } /** A fetch that records into the request log (handed to EditClient so /api/edit shows up too). */ loggedFetch: typeof fetch = async (input, init) => { const t0 = performance.now() const resp = await this.doFetch(input, init) let request: unknown try { request = init?.body ? JSON.parse(String(init.body)) : undefined } catch { /* not JSON */ } const text = await resp.clone().text().catch(() => '') let response: unknown = text try { response = JSON.parse(text) } catch { /* keep text */ } this.opts.onLog?.({ n: ++this.n, method: init?.method ?? 'GET', url: String(input), status: resp.status, ms: Math.round(performance.now() - t0), request, response }) return resp } get = (path: string) => this.request('GET', path) post = (path: string, body?: unknown) => this.request('POST', path, body) me = () => this.get<{ [k: string]: unknown }>('/api/auth/me') node = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}`) /** Find existing nodes: `GET /api/nodes?type=&q=&limit=&offset=`. Empty filter lists everything (paged). */ async searchNodes(opts: { type?: string; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: CmsNode[]; total: number }> { const p = new URLSearchParams() if (opts.type) p.set('type', opts.type) if (opts.q) p.set('q', opts.q) p.set('limit', String(opts.limit ?? 20)) if (opts.offset) p.set('offset', String(opts.offset)) const r = await this.get<{ items: CmsNode[]; total: number }>(`/api/nodes?${p}`) return { items: r.items ?? [], total: r.total ?? (r.items ?? []).length } } edges = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}/edges`) edit = (ops: Op[]) => this.post('/api/edit', { ops }) publish = (ids: string[]) => this.post<{ publishedAt?: string; results?: { id: string; error?: string }[] }>('/api/publish-roots', { ids }) /** Schemas are code: cache for the session. */ schema(type: string): Promise { let p = this.schemas.get(type) if (!p) { p = this.get(`/api/schemas/${encodeURIComponent(type)}`) p.catch(() => this.schemas.delete(type)) this.schemas.set(type, p) } return p } types(): Promise { return (this.typesP ??= this.get('/api/types').then((r) => (Array.isArray(r) ? r : r.types ?? []))) } blueprints(): Promise { return (this.blueprintsP ??= this.get('/api/blueprints').catch(() => [])) } }