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:
18
server/api/__seo__/site.get.ts
Normal file
18
server/api/__seo__/site.get.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// GET /api/__seo__/site: the CMS's site answer (GET <apiBase>/site, SEO
|
||||
// plugin v0.2.0) through the layer's server and its cache, for useSeoSite()
|
||||
// on the server and in the browser. Always 200 with {site: <answer> | null}:
|
||||
// null with the layer off, with a backend before v0.2.0 (its /site answers
|
||||
// 404) or with a backend that has not answered since start. Never
|
||||
// route-cached ('/api/__seo__/**' cache false) and never indexed.
|
||||
|
||||
import { seoServerConfig } from '../../lib/config.ts'
|
||||
import { siteAnswer } from '../../lib/site.ts'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
|
||||
const cfg = seoServerConfig(useRuntimeConfig(event))
|
||||
if (!cfg.enabled) return { site: null }
|
||||
const forwarded = event.context.seoForwardedFor as string | undefined
|
||||
const state = await siteAnswer(cfg, forwarded ? { 'x-forwarded-for': forwarded } : {})
|
||||
return { site: state.state === 'ok' ? state.site : null }
|
||||
})
|
||||
@@ -17,8 +17,10 @@ export interface SeoServerConfig {
|
||||
resolveCacheTtl: number
|
||||
/** Bearer token of POST /notfound (NUXT_SEO_NOTFOUND_TOKEN); empty: no reports. */
|
||||
notfoundToken: string
|
||||
/** Bearer token of the route report (33-08); falls back to notfoundToken. */
|
||||
/** Bearer token of the route report; falls back to notfoundToken. */
|
||||
routeReportToken: string
|
||||
/** Module option seo.routeReport (NUXT_PUBLIC_SEO_ROUTE_REPORT): report the static routes at start. */
|
||||
routeReport: boolean
|
||||
/** Module option seo.rootFiles: serve robots.txt, the sitemaps, llms.txt and the listed root files. */
|
||||
rootFiles: boolean
|
||||
/** Module option seo.resolveFiles: file extensions the middleware still resolves (['pdf']). */
|
||||
@@ -74,6 +76,7 @@ export function seoServerConfig(config: unknown): SeoServerConfig {
|
||||
resolveCacheTtl: num(seo.resolveCacheTtl, DEFAULT_RESOLVE_CACHE_TTL),
|
||||
notfoundToken,
|
||||
routeReportToken: str(seo.routeReportToken) || notfoundToken,
|
||||
routeReport: pub.routeReport !== false && pub.routeReport !== 'false',
|
||||
rootFiles: pub.rootFiles !== false && pub.rootFiles !== 'false',
|
||||
resolveFiles,
|
||||
}
|
||||
|
||||
84
server/lib/drift.ts
Normal file
84
server/lib/drift.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// The startup drift check (D-20c, RESEARCH Open Question 9): Nuxt routes
|
||||
// and the layer's pagination and HTML-sitemap options are fixed at build
|
||||
// time, while the CMS decides content URL patterns, the pagination form and
|
||||
// the HTML-sitemap paths. At server start the layer compares the two and
|
||||
// logs one warning per mismatch, so an editor's change in the CMS that the
|
||||
// frontend does not follow is visible. Pure, so `node --test` drives it.
|
||||
|
||||
import { patternCovers } from '../../lib/guard.ts'
|
||||
import type { RoutePattern } from '../../lib/routes.ts'
|
||||
import type { SeoSiteAnswer } from '../../app/utils/types.ts'
|
||||
|
||||
export const DRIFT_PREFIX = '[vue-seo-layer] drift:'
|
||||
|
||||
export interface LayerPaginationOption {
|
||||
form: 'query' | 'path'
|
||||
param: string
|
||||
segments: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DriftInput {
|
||||
site: SeoSiteAnswer
|
||||
routes: readonly RoutePattern[]
|
||||
pagination: LayerPaginationOption
|
||||
/** seo.htmlSitemap.paths, or false when the layer serves no HTML sitemap. */
|
||||
htmlSitemap: Record<string, string> | false
|
||||
}
|
||||
|
||||
/** A CMS URL pattern in route-rule form: '{slug}' and ':slug' alike become ':slug'. */
|
||||
const cmsPattern = (p: string) => p.replace(/\{(\w+)\}/g, ':$1').split('?')[0]!
|
||||
|
||||
/**
|
||||
* True when a Nuxt route serves the CMS pattern: a route of the same shape
|
||||
* (a parameter stands for any segment), or a catch-all under a static
|
||||
* prefix ('/docs/**'). A catch-all at the root ('[...slug].vue') is a
|
||||
* generic page renderer and does not count as a route for a content type.
|
||||
*/
|
||||
export function routeServes(route: RoutePattern, pattern: string): boolean {
|
||||
if (route.catchAll && route.pattern === '/**') return false
|
||||
return patternCovers(route.pattern, cmsPattern(pattern))
|
||||
}
|
||||
|
||||
/** One message per mismatch between the CMS's site answer and the build. */
|
||||
export function driftWarnings(input: DriftInput): string[] {
|
||||
const out: string[] = []
|
||||
const site = input.site ?? {}
|
||||
|
||||
for (const r of Array.isArray(site.routes) ? site.routes : []) {
|
||||
if (!r || typeof r.pattern !== 'string' || r.pattern === '') continue
|
||||
if (!input.routes.some((route) => routeServes(route, r.pattern))) {
|
||||
out.push(`${DRIFT_PREFIX} the CMS's ${r.type || 'content'} URLs (${r.pattern}) match no Nuxt route`)
|
||||
}
|
||||
}
|
||||
|
||||
const p = site.pagination
|
||||
if (p && typeof p === 'object') {
|
||||
const form = p.form === 'path' ? 'path' : p.form === 'query' ? 'query' : null
|
||||
if (form && form !== input.pagination.form) {
|
||||
out.push(`${DRIFT_PREFIX} the CMS paginates in the ${form} form, the layer was built with ${input.pagination.form} (seo.pagination.form)`)
|
||||
} else if (form === 'query' && typeof p.param === 'string' && p.param && p.param !== input.pagination.param) {
|
||||
out.push(`${DRIFT_PREFIX} the CMS's page parameter is ${p.param}, the layer's is ${input.pagination.param} (seo.pagination.param)`)
|
||||
} else if (form === 'path') {
|
||||
for (const [locale, segment] of Object.entries(p.segments ?? {})) {
|
||||
const own = input.pagination.segments[locale]
|
||||
if (typeof segment === 'string' && segment && own !== segment) {
|
||||
out.push(`${DRIFT_PREFIX} the CMS's ${locale} page segment is ${segment}, the layer's is ${own ?? 'unset'} (seo.pagination.segments)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const html = site.html_sitemap
|
||||
if (html && html.enabled === true && html.paths && typeof html.paths === 'object') {
|
||||
if (input.htmlSitemap === false) {
|
||||
out.push(`${DRIFT_PREFIX} the CMS has an HTML sitemap, the layer serves none (seo.htmlSitemap is false)`)
|
||||
} else {
|
||||
for (const [locale, path] of Object.entries(html.paths)) {
|
||||
if (typeof path !== 'string' || path === '') continue
|
||||
const own = input.htmlSitemap[locale]
|
||||
if (own !== path) out.push(`${DRIFT_PREFIX} the CMS serves the ${locale} HTML sitemap at ${path}, the layer at ${own ?? 'no path'} (seo.htmlSitemap.paths)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
server/lib/routeReport.ts
Normal file
33
server/lib/routeReport.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// The route report (D-33): at server start the layer posts its static
|
||||
// routes (#seo-routes, built from the pages) to the SEO plugin's
|
||||
// POST /_seo/api/v1/registry/routes with the route-report token, so the
|
||||
// CMS's page registry knows every frontend page. Pure helpers for the
|
||||
// Nitro plugin server/plugins/seo-route-report.ts, so `node --test` drives
|
||||
// them.
|
||||
|
||||
/** The plugin's limits: at most 2000 paths of at most 2048 bytes each. */
|
||||
export const ROUTE_REPORT_MAX_ROUTES = 2000
|
||||
export const ROUTE_REPORT_MAX_BYTES = 2048
|
||||
/** The one retry after a failed report waits this long. */
|
||||
export const ROUTE_REPORT_RETRY_MS = 60_000
|
||||
|
||||
/** The request body: the paths within the plugin's limits. */
|
||||
export function routeReportBody(paths: readonly string[]): { routes: string[] } {
|
||||
const routes = paths
|
||||
.filter((p) => typeof p === 'string' && p.startsWith('/') && Buffer.byteLength(p, 'utf8') <= ROUTE_REPORT_MAX_BYTES)
|
||||
.slice(0, ROUTE_REPORT_MAX_ROUTES)
|
||||
return { routes }
|
||||
}
|
||||
|
||||
/**
|
||||
* What an answer means: sent (2xx), absent (404: a backend without the
|
||||
* page registry, or with both tokens empty; never retried), refused (401 or
|
||||
* 403: the token is wrong; never retried) or retry (no answer, or any other
|
||||
* status: tried once more after ROUTE_REPORT_RETRY_MS).
|
||||
*/
|
||||
export function routeReportOutcome(status: number | null): 'sent' | 'absent' | 'refused' | 'retry' {
|
||||
if (status !== null && status >= 200 && status < 300) return 'sent'
|
||||
if (status === 404) return 'absent'
|
||||
if (status === 401 || status === 403) return 'refused'
|
||||
return 'retry'
|
||||
}
|
||||
40
server/plugins/seo-drift.ts
Normal file
40
server/plugins/seo-drift.ts
Normal 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?.()
|
||||
})
|
||||
51
server/plugins/seo-route-report.ts
Normal file
51
server/plugins/seo-route-report.ts
Normal 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?.()
|
||||
})
|
||||
8
server/seo-routes.d.ts
vendored
Normal file
8
server/seo-routes.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// The server template the layer's module writes from the resolved pages
|
||||
// (lib/routes.ts, addServerTemplate).
|
||||
declare module '#seo-routes' {
|
||||
/** Paths without parameters that may be indexed (the route report). */
|
||||
export const staticRoutes: string[]
|
||||
/** Every page's route pattern (the drift check). */
|
||||
export const routePatterns: Array<{ pattern: string; catchAll: boolean }>
|
||||
}
|
||||
Reference in New Issue
Block a user