import 'server-only'; import { isRecord, normalizePageResolution, normalizePageTeaserList, normalizeSitemapEntries, normalizeSiteSettings, sitemapLastPage } from '../data/normalize'; import type { PageRequest, PageResolution, PageTeaserList, SitemapEntry, SiteSettings } from '../types'; import { blogListCacheTags, pageCacheTags, settingsCacheTags, sitemapCacheTags } from './cache-tags'; /** * Ceiling on sitemap round trips, so a runaway `lastPage` cannot spin forever. At the backend's * 1000 rows per request this covers 50,000 URLs — the sitemap protocol's own limit per file. */ const SITEMAP_MAX_REQUESTS = 50; function extractPreviewRequest(payload: unknown): PageRequest { if (isRecord(payload)) { const root = isRecord(payload['data']) ? payload['data'] : payload; const page = isRecord(root['page']) ? root['page'] : root; return { locale: typeof page['locale'] === 'string' ? page['locale'] : 'en', path: typeof page['path'] === 'string' ? page['path'] : '' }; } return { locale: 'en', path: '' }; } export class WebRuntimeAPI { constructor(private readonly baseUrl: string) {} async resolvePage(request: PageRequest): Promise { const payload = await this.request('/web-manager/public/pages/resolve', { params: { locale: request.locale, path: request.path }, next: { revalidate: 60, tags: pageCacheTags(request.locale, request.path) } }); if (payload === null) { return null; } return normalizePageResolution(payload, request); } async listBlogPosts(request: { locale: string; page?: number; perPage?: number; }): Promise { const payload = await this.request('/web-manager/public/blog/pages', { params: { locale: request.locale, page: String(request.page ?? 1), perPage: String(request.perPage ?? 12) }, next: { revalidate: 60, tags: blogListCacheTags(request.locale) } }); return normalizePageTeaserList(payload); } /** * Every published page, walked page by page. Tagged like any other public read, so * publishing refreshes the sitemap rather than leaving it a day out of date. */ async listSitemapPages(): Promise { const entries: SitemapEntry[] = []; let page = 1; let lastPage = 1; do { const payload = await this.request('/web-manager/public/pages/sitemap', { params: { page: String(page) }, next: { revalidate: 3600, tags: sitemapCacheTags() } }); entries.push(...normalizeSitemapEntries(payload)); lastPage = Math.min(sitemapLastPage(payload), SITEMAP_MAX_REQUESTS); page += 1; } while (page <= lastPage); return entries; } async getSettings(locale: string): Promise { const payload = await this.request('/web-manager/public/settings', { params: { locale }, next: { revalidate: 60, tags: settingsCacheTags(locale) } }); if (payload === null) { return null; } const rawSettings = isRecord(payload) && isRecord(payload['settings']) ? payload['settings'] : payload; return normalizeSiteSettings(rawSettings, locale); } async getPreview(token: string): Promise { const payload = await this.request('/web-manager/public/pages/preview', { params: { token }, cache: 'no-store' }); if (payload === null) { return null; } return normalizePageResolution(payload, extractPreviewRequest(payload)); } private async request( path: string, { params, ...init }: RequestInit & { params: Record } ): Promise { const url = new URL(`${this.baseUrl}${path}`); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } const response = await fetch(url, { ...init, headers: { Accept: 'application/json' } }); if (response.status === 404) { return null; } if (!response.ok) { throw new Error(`Web Manager request to ${path} failed with status ${response.status}.`); } return await response.json(); } } let api: WebRuntimeAPI | null = null; export function getWebRuntimeAPI(): WebRuntimeAPI { if (!api) { const baseUrl = process.env['BACKEND_API_URL']?.trim().replace(/\/$/, ''); if (!baseUrl) { throw new Error('BACKEND_API_URL is required'); } api = new WebRuntimeAPI(baseUrl); } return api; }