TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
+153
View File
@@ -0,0 +1,153 @@
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<PageResolution | null> {
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<PageTeaserList> {
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<SitemapEntry[]> {
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<SiteSettings | null> {
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<PageResolution | null> {
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<string, string> }
): Promise<unknown> {
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;
}