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
+44
View File
@@ -0,0 +1,44 @@
/**
* Cache tags attached to public Web Manager fetches. The backend references
* the same tag names when it asks `/api/revalidate` to purge cached content,
* so any change here must stay in sync with the backend revalidation service
* (`apps/backend/app/base_modules/WebManager/services/web_revalidation_service.ts`).
*
* `WEB_MANAGER_TAG` is on every fetch and exists as the deliberate sledgehammer:
* the backend purges it for structural changes (a page created, deleted or moved)
*/
export const WEB_MANAGER_TAG = 'web-manager';
export function pageCacheTag(locale: string, path: string): string {
return `${WEB_MANAGER_TAG}:page:${locale}:${path || '/'}`;
}
export function settingsCacheTag(locale: string): string {
return `${WEB_MANAGER_TAG}:settings:${locale}`;
}
export function blogCacheTag(locale: string): string {
return `${WEB_MANAGER_TAG}:blog:${locale}`;
}
export function pageCacheTags(locale: string, path: string): string[] {
return [WEB_MANAGER_TAG, pageCacheTag(locale, path), settingsCacheTag(locale)];
}
export function blogListCacheTags(locale: string): string[] {
return [WEB_MANAGER_TAG, blogCacheTag(locale)];
}
export function settingsCacheTags(locale: string): string[] {
return [WEB_MANAGER_TAG, settingsCacheTag(locale)];
}
/**
* The sitemap spans every locale and every page, so the only tag that can invalidate it is the
* site-wide one — which the backend purges for exactly the changes that alter the URL set
* (a page created, deleted, moved, published or unpublished).
*/
export function sitemapCacheTags(): string[] {
return [WEB_MANAGER_TAG];
}
+46
View File
@@ -0,0 +1,46 @@
import 'server-only';
import { cache } from 'react';
import { createEmptySiteSettings, normalizeRoutePath } from '../data/normalize';
import type { PageRenderData } from '../types';
import { getWebRuntimeAPI } from './web-runtime-api';
export const resolvePageRenderData = cache(
async (locale: string, requestedPath: string): Promise<PageRenderData | null> => {
const api = getWebRuntimeAPI();
const request = {
locale: locale.trim(),
path: normalizeRoutePath(requestedPath)
};
const resolution = await api.resolvePage(request);
if (!resolution || resolution.page.status !== 'published') {
return null;
}
const settings = resolution.settings ?? (await api.getSettings(request.locale));
return {
page: resolution.page,
settings: settings ?? createEmptySiteSettings(request.locale),
languageAlternatives: resolution.languageAlternatives
};
}
);
export async function resolvePreviewRenderData(token: string): Promise<PageRenderData | null> {
if (!token) {
return null;
}
const resolution = await getWebRuntimeAPI().getPreview(token);
if (!resolution) {
return null;
}
return {
page: resolution.page,
settings: resolution.settings ?? createEmptySiteSettings(resolution.page.locale),
languageAlternatives: resolution.languageAlternatives
};
}
+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;
}