feat: route report and drift check at server start

- the module writes #seo-routes (static paths and route patterns) from the
  resolved pages; options seo.routeReport, seo.pagination and
  seo.htmlSitemap reach the public runtime config
- seo-route-report: POST /registry/routes once per process with the
  route-report token, one retry after 60 s on failure, nothing without a
  token, with the layer off or with routeReport false
- seo-drift: one "[vue-seo-layer] drift:" warning per mismatch between
  the CMS's site answer (URL patterns, pagination, HTML sitemap paths)
  and the build
- GET /api/__seo__/site and useSeoSite()
- smoke grammar routereport and log, fixture registry route, drift run
This commit is contained in:
Jakub Zych
2026-11-04 11:49:21 +01:00
parent 9cc2cda924
commit 3abc986f42
21 changed files with 599 additions and 20 deletions

View File

@@ -0,0 +1,40 @@
// The startup drift check (D-20c): compares the CMS's content-type URL
// patterns, pagination form and segments and HTML-sitemap paths (the site
// answer, GET <apiBase>/site) with the build's routes (#seo-routes) and the
// layer's seo.pagination and seo.htmlSitemap options, and logs one
// "[vue-seo-layer] drift: ..." warning per mismatch. It runs once after the
// plugin returns, never blocks the start and never fails it; a backend
// without a site answer (before SEO v0.2.0) or one that does not answer is
// skipped. Off with the layer off or while prerendering.
import { routePatterns } from '#seo-routes'
import { seoServerConfig } from '../lib/config.ts'
import { driftWarnings, type LayerPaginationOption } from '../lib/drift.ts'
import { siteAnswer } from '../lib/site.ts'
export default defineNitroPlugin(() => {
if (import.meta.prerender) return
const config = useRuntimeConfig()
const cfg = seoServerConfig(config)
if (!cfg.enabled) return
const pub = ((config.public as Record<string, unknown>).seo ?? {}) as {
pagination?: LayerPaginationOption
htmlSitemap?: { paths?: Record<string, string> } | false
}
const pagination: LayerPaginationOption = {
form: pub.pagination?.form === 'path' ? 'path' : 'query',
param: pub.pagination?.param || 'page',
segments: { ...(pub.pagination?.segments ?? {}) },
}
const htmlSitemap = pub.htmlSitemap === false || (pub.htmlSitemap as unknown) === 'false' ? false : { ...(pub.htmlSitemap?.paths ?? {}) }
setTimeout(async () => {
try {
const state = await siteAnswer(cfg, {})
if (state.state !== 'ok') return
for (const warning of driftWarnings({ site: state.site, routes: routePatterns, pagination, htmlSitemap })) console.warn(warning)
} catch (err) {
console.warn('[vue-seo-layer] drift check failed:', (err as Error)?.message ?? err)
}
}, 0).unref?.()
})

View File

@@ -0,0 +1,51 @@
// Reports the site's static routes to the CMS's page registry once per
// server process (D-33): POST <apiBase>/registry/routes with
// `Authorization: Bearer <route-report token>` and {routes: [path, ...]}.
// The paths come from the build (#seo-routes): pages without parameters,
// without the ones whose meta keeps them out of the index (requiresAuth, a
// noindex seo.robots) and without seo.noindexRoutes. It never delays the
// start: the report goes out after the plugin returns, a failure (no
// answer, a 5xx) is tried once more after 60 s, and a 404 (a backend without
// the registry) or a refused token is logged once and not retried. Nothing
// is sent with the layer off (seo.cms, public.seo.enabled), with
// seo.routeReport false, without a token (NUXT_SEO_ROUTE_REPORT_TOKEN, else
// NUXT_SEO_NOTFOUND_TOKEN) or while prerendering. The token stays in the
// server-only runtime config and goes to the configured apiBase only.
import { staticRoutes } from '#seo-routes'
import { seoServerConfig } from '../lib/config.ts'
import { ROUTE_REPORT_RETRY_MS, routeReportBody, routeReportOutcome } from '../lib/routeReport.ts'
export default defineNitroPlugin(() => {
if (import.meta.prerender) return
const cfg = seoServerConfig(useRuntimeConfig())
if (!cfg.enabled || !cfg.routeReport || !cfg.routeReportToken) return
const body = routeReportBody(staticRoutes)
const send = async (attempt: number): Promise<void> => {
let status: number | null = null
let reason = ''
try {
const res = await $fetch.raw('/registry/routes', {
baseURL: cfg.apiBase,
method: 'POST',
body,
headers: { authorization: `Bearer ${cfg.routeReportToken}` },
timeout: Math.max(cfg.timeoutMs, 5000),
ignoreResponseError: true,
})
status = res.status
} catch (err) {
reason = (err as Error)?.message ?? String(err)
}
const outcome = routeReportOutcome(status)
if (outcome === 'sent') console.info(`[vue-seo-layer] route report: ${body.routes.length} routes sent`)
else if (outcome === 'absent') console.info('[vue-seo-layer] route report: the CMS has no page registry (404), nothing reported')
else if (outcome === 'refused') console.warn(`[vue-seo-layer] route report: the CMS refused the token (${status}); set NUXT_SEO_ROUTE_REPORT_TOKEN to golem15.seo.route_report_token`)
else if (attempt === 1) {
console.warn(`[vue-seo-layer] route report failed (${status ?? reason}); trying again in ${ROUTE_REPORT_RETRY_MS / 1000} s`)
setTimeout(() => void send(2), ROUTE_REPORT_RETRY_MS).unref?.()
} else console.warn(`[vue-seo-layer] route report failed again (${status ?? reason}); not retried before the next start`)
}
setTimeout(() => void send(1), 0).unref?.()
})