Files
vue-seo-layer/server/lib/site.ts
Jakub Zych d1629439c7 feat: root files from the CMS and the 404 report
- server/middleware/seo-root.ts proxies /robots.txt, /sitemap.xml,
  /sitemap-<name>.xml, /llms.txt and the verification and IndexNow key
  files the site answer lists in root_files, with the backend's status,
  content type and X-Robots-Tag; a backend failure is 503 Retry-After 60
- server/lib/site.ts caches GET /_seo/api/v1/site (300 s, cleared by the
  purge); a backend without it (404) keeps the v0.1.1 behaviour: no root
  proxies, the verification lookup by name
- the 404 report and the verification lookup honour seo.cms and rootFiles
- smoke runs for the layer off (seo.cms and the v0.1.1 enabled switch), the
  CMS down (fail open) and no 404 token; npm run purge:check
2026-11-04 09:26:00 +01:00

101 lines
4.6 KiB
TypeScript

// The site answer (GET <apiBase>/site, SEO plugin v0.2.0) and the root-file
// mapping built on it. The answer is cached for SITE_CACHE_TTL_MS; the site
// cache purge clears it. Three states:
//
// ok the backend answered: its root_files list decides which
// one-segment files are proxied
// absent the backend answered 404 (no SEO plugin, or one before
// v0.2.0): the layer keeps its v0.1.1 behaviour (verification
// files looked up by name, robots.txt and the sitemaps left to
// the site's proxy)
// unknown the backend did not answer: the last ok answer is used when
// there is one; nothing is cached
//
// Nitro auto-imports ($fetch, useRuntimeConfig) are used inside the
// functions only, so `node --test` can import this file.
import type { SeoSiteAnswer } from '../../app/utils/types.ts'
import type { SeoServerConfig } from './config.ts'
import { createLru } from './lru.ts'
export const SITE_CACHE_TTL_MS = 300_000
export type SeoSiteState = { state: 'ok'; site: SeoSiteAnswer } | { state: 'absent' } | { state: 'unknown' }
const cache = createLru<SeoSiteState>({ max: 1, ttlMs: SITE_CACHE_TTL_MS })
let lastOk: SeoSiteState | null = null
/** Empties the site cache (the purge calls it through clearSeoCaches). */
export function clearSiteCache(): void {
cache.clear()
lastOk = null
}
export function siteCacheSize(): number {
return cache.size
}
/** The site state from a fetch result: status and parsed body. */
export function siteState(status: number | null, body: unknown): SeoSiteState {
if (status === 200 && body !== null && typeof body === 'object' && !Array.isArray(body)) return { state: 'ok', site: body as SeoSiteAnswer }
if (status === 404) return { state: 'absent' }
return { state: 'unknown' }
}
/** The cached site state for this server (cfg.enabled is the caller's check). */
export async function siteAnswer(cfg: SeoServerConfig, headers: Record<string, string>): Promise<SeoSiteState> {
const got = await cache.load(
'site',
async () => {
try {
const res = await $fetch.raw('/site', { baseURL: cfg.apiBase, headers, timeout: cfg.timeoutMs, ignoreResponseError: true })
return siteState(res.status, res._data)
} catch {
return { state: 'unknown' } as SeoSiteState
}
},
(s) => s.state !== 'unknown',
)
if (got.state === 'ok') lastOk = got
if (got.state === 'unknown' && lastOk) return lastOk
return got
}
/** A one-segment file name the site answer may list: [A-Za-z0-9._-]+ ending .html, .txt or .xml. */
const ROOT_FILE = /^\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:html|txt|xml))$/
/** The IndexNow key file name: 8 to 128 of [A-Za-z0-9-], then .txt. */
const INDEXNOW_KEY_FILE = /^[A-Za-z0-9-]{8,128}\.txt$/
/** Sitemap files of the index: sitemap-<slug>-<n>.xml, sitemap-news.xml. */
const SITEMAP_FILE = /^\/(sitemap-[a-z0-9-]+\.xml)$/
const FIXED = new Set(['/robots.txt', '/sitemap.xml', '/llms.txt'])
export interface RootTarget {
kind: 'root' | 'sitemap' | 'verification' | 'indexnow'
/** Absolute backend URL built from the server-only bases and an allow-listed name only. */
url: string
}
/**
* Where a root file request goes on the backend, in the order to try (the
* first answer that is not 404 is served), or null when the layer does not
* serve the path. robots.txt, sitemap.xml, llms.txt and sitemap-<name>.xml
* are proxied unless the site state is absent; any other one-segment
* .html, .txt or .xml file only when the site answer lists it in
* root_files (a verification file, then the IndexNow key file). Nothing
* from the request but the matched name reaches the URL.
*/
export function rootFileTargets(path: string, site: SeoSiteState, backendBase: string, apiBase: string): RootTarget[] | null {
const pathname = path.split('?')[0] ?? ''
if (FIXED.has(pathname)) return site.state === 'absent' ? null : [{ kind: 'root', url: `${backendBase}${pathname}` }]
const sitemap = SITEMAP_FILE.exec(pathname)
if (sitemap) return site.state === 'absent' ? null : [{ kind: 'sitemap', url: `${backendBase}/_sitemap/files/${sitemap[1]}` }]
const file = ROOT_FILE.exec(pathname)
if (!file || !file[1] || site.state !== 'ok') return null
const name = file[1]
const listed = Array.isArray(site.site.root_files) ? site.site.root_files : []
if (!listed.includes(name)) return null
const targets: RootTarget[] = [{ kind: 'verification', url: `${apiBase}/verification/${encodeURIComponent(name)}` }]
if (INDEXNOW_KEY_FILE.test(name)) targets.push({ kind: 'indexnow', url: `${backendBase}/_sitemap/indexnow/${encodeURIComponent(name)}` })
return targets
}