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
This commit is contained in:
Jakub Zych
2026-11-04 09:26:00 +01:00
parent 691fa09b0f
commit d1629439c7
18 changed files with 342 additions and 16 deletions

View File

@@ -9,7 +9,8 @@
"build:playground": "nuxt build .playground",
"test": "node --test --test-reporter=tap tests/*.test.ts",
"dev:prepare": "nuxt prepare .playground",
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt --no-site && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt"
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt --no-site && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_CMS=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_ENABLED=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-down.txt --env NUXT_SEO_API_BASE=http://127.0.0.1:1/_seo/api/v1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-notoken.txt --env NUXT_SEO_NOTFOUND_TOKEN=",
"purge:check": "node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa && node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa"
},
"engines": {
"node": ">=22.18"

View File

@@ -16,6 +16,7 @@ import { seoAnswer } from '../../app/utils/seo.ts'
import type { SeoResolveAnswer } from '../../app/utils/types.ts'
import { seoServerConfig, type SeoServerConfig } from './config.ts'
import { createLru, type Lru } from './lru.ts'
import { clearSiteCache, siteCacheSize } from './site.ts'
/** Most resolve answers kept. */
export const RESOLVE_CACHE_MAX = 1000
@@ -31,14 +32,15 @@ function resolveCache(ttlSeconds: number): Lru<SeoResolveAnswer | null> {
return cache
}
/** Empties the layer's SEO caches (the resolve answers); the site cache purge calls it. */
/** Empties the layer's SEO caches (resolve answers and the site answer); the site cache purge calls it. */
export function clearSeoCaches(): void {
cache?.clear()
clearSiteCache()
}
/** How many answers the layer's caches hold (tests and diagnostics). */
export function seoCacheSizes(): { resolve: number } {
return { resolve: cache?.size ?? 0 }
export function seoCacheSizes(): { resolve: number; site: number } {
return { resolve: cache?.size ?? 0, site: siteCacheSize() }
}
/** The x-forwarded-for value the SEO calls carry for this request. */

100
server/lib/site.ts Normal file
View File

@@ -0,0 +1,100 @@
// 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
}

View File

@@ -0,0 +1,65 @@
// Serves the site-root files from the CMS backend: /robots.txt,
// /sitemap.xml, /sitemap-<name>.xml (the Go sitemap's files under
// /_sitemap/files/), /llms.txt, and each verification file and the IndexNow
// key file the site answer lists in root_files. The backend's status,
// content type and X-Robots-Tag are passed on; a backend that fails (no
// answer, 5xx) gives 503 with Retry-After: 60, so a crawler retries instead
// of reading a missing robots.txt as "everything allowed". Any other path
// passes through.
//
// With a backend before SEO v0.2.0 (the site answer is 404) nothing is
// proxied here: robots.txt and the sitemaps stay with the site's proxy and
// verification files keep the v0.1.1 lookup (seo-verification.ts). Off with
// the module option seo.rootFiles false or the layer switched off. The
// backend URLs come from the server-only bases and an allow-listed name only
// (rootFileTargets), never from the request.
import { seoServerConfig } from '../lib/config.ts'
import { seoForwardedFor } from '../lib/resolve.ts'
import { rootFileTargets, siteAnswer } from '../lib/site.ts'
const RETRY_AFTER = '60'
export default defineEventHandler(async (event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') return
const pathname = event.path.split('?')[0] ?? ''
// Cheap test before any call: only one-segment .html, .txt and .xml paths.
if (!/^\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:html|txt|xml)$/.test(pathname)) return
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled || !cfg.rootFiles) return
const headers = seoForwardedFor(event)
const targets = rootFileTargets(pathname, await siteAnswer(cfg, headers), cfg.backendBase, cfg.apiBase)
if (!targets) return
let res: Awaited<ReturnType<typeof $fetch.raw>> | null = null
let kind = targets[0]?.kind
for (const target of targets) {
res = await $fetch
.raw(target.url, { responseType: 'text', ignoreResponseError: true, redirect: 'manual', headers, timeout: cfg.timeoutMs })
.catch(() => null)
kind = target.kind
if (!res || res.status !== 404) break
}
if (!res || res.status >= 500) {
setResponseStatus(event, 503, 'Service Unavailable')
setResponseHeaders(event, { 'retry-after': RETRY_AFTER, 'cache-control': 'no-store', 'content-type': 'text/plain; charset=utf-8' })
return event.method === 'HEAD' ? '' : 'Service Unavailable'
}
setResponseStatus(event, res.status)
const out: Record<string, string> = {
'content-type': res.headers.get('content-type') ?? 'text/plain; charset=utf-8',
'x-content-type-options': 'nosniff',
}
const robots = res.headers.get('x-robots-tag')
if (robots) out['x-robots-tag'] = robots
if (kind === 'verification' && res.status === 200) {
// The v0.1.1 headers of a verification file.
out['content-security-policy'] = res.headers.get('content-security-policy') ?? "default-src 'none'; sandbox"
out['x-robots-tag'] = robots ?? 'noindex'
}
const cacheControl = res.headers.get('cache-control')
if (cacheControl) out['cache-control'] = cacheControl
setResponseHeaders(event, out)
return event.method === 'HEAD' ? '' : ((res._data as string | undefined) ?? '')
})

View File

@@ -4,8 +4,16 @@
// them. Only a single-segment .html, .txt or .xml path is looked up, never a
// name the site serves itself; anything the backend does not answer 200
// falls through to the normal routes.
//
// This is the v0.1.1 lookup, for a backend without the site answer (SEO
// plugin before v0.2.0, /site answers 404) or one that does not answer it
// now. With a site answer, seo-root.ts serves exactly the files its
// root_files lists and every other name passes through untouched.
import { seoVerificationName } from '../../app/utils/seo.ts'
import { seoServerConfig } from '../lib/config.ts'
import { seoForwardedFor } from '../lib/resolve.ts'
import { siteAnswer } from '../lib/site.ts'
const RESERVED = ['robots.txt', 'sitemap.xml', 'sitemap.xsl', 'rss.xml', 'feed.xml', 'atom.xml', 'llms.txt']
@@ -13,15 +21,17 @@ export default defineEventHandler(async (event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') return
const name = seoVerificationName(event.path, RESERVED)
if (!name) return
const config = useRuntimeConfig(event)
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
const seo = config.seo as { apiBase: string; timeoutMs?: number }
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled || !cfg.rootFiles) return
const headers = seoForwardedFor(event)
if ((await siteAnswer(cfg, headers)).state === 'ok') return
const res = await $fetch
.raw(`/verification/${encodeURIComponent(name)}`, {
baseURL: seo.apiBase,
baseURL: cfg.apiBase,
responseType: 'text',
ignoreResponseError: true,
timeout: Number(seo.timeoutMs) || 1500,
headers,
timeout: cfg.timeoutMs,
})
.catch(() => null)
if (!res || res.status !== 200) return

View File

@@ -4,11 +4,14 @@
// skips afterResponse, so both the `error` hook and `afterResponse` report,
// once per request. Only page views count: GET requests that accept
// text/html, never Nuxt's own paths (/_nuxt, /__nuxt_error, …) or the API
// routes, and nothing without the token configured. A failed report is
// logged once and never touches the response.
// routes, and nothing without the token configured or with the layer off
// (public.seo.enabled or seo.cms false). The report never delays the
// response: it is sent after it (event.waitUntil keeps it alive). A failed
// report is logged once and never touches the response.
import type { H3Event } from 'h3'
import { seoResolvableUrl } from '../../app/utils/seo.ts'
import { seoServerConfig } from '../lib/config.ts'
let warned = false
@@ -16,10 +19,9 @@ function report(event: H3Event) {
if (event.context.seoNotFoundReported) return
if (event.method !== 'GET') return
if (!(getRequestHeader(event, 'accept') ?? '').includes('text/html')) return
const config = useRuntimeConfig(event)
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
const seo = config.seo as { apiBase: string; notfoundToken?: string; timeoutMs?: number }
const token = seo.notfoundToken?.trim()
const seo = seoServerConfig(useRuntimeConfig(event))
if (!seo.enabled) return
const token = seo.notfoundToken
if (!token) return
const url = seoResolvableUrl(event.path)
if (!url) return
@@ -32,7 +34,7 @@ function report(event: H3Event) {
method: 'POST',
body: { url: url.slice(0, 2048), referer: referer.slice(0, 1024) },
headers: { authorization: `Bearer ${token}`, ...(forwarded ? { 'x-forwarded-for': forwarded } : {}) },
timeout: Number(seo.timeoutMs) || 1500,
timeout: seo.timeoutMs,
}).catch((err: unknown) => {
if (!warned) {
warned = true

View File

@@ -0,0 +1,9 @@
# The CMS unreachable (NUXT_SEO_API_BASE on a closed port): pages render
# normally with their own head (fail open); a root file is 503 with
# Retry-After, so crawlers retry; verification lookups fall through.
200 /about contains=<title>Own about | Playground</title> notcontains=About us | Acme noheader=X-Robots-Tag count=application/ld+json:0
200 /plain contains=<title>Plain page | Playground</title>
503 /robots.txt header=Retry-After: 60
503 /sitemap-posts-1.xml header=Retry-After: 60
404 /google0123.html
apicalls 0

View File

@@ -0,0 +1,3 @@
# No 404 token (NUXT_SEO_NOTFOUND_TOKEN empty): a 404 page sends no report.
404 /kontakt
noreport /kontakt

View File

@@ -0,0 +1,13 @@
# The layer switched off: seo.cms false (NUXT_PUBLIC_SEO_CMS=false) or the
# v0.1.1 switch public.seo.enabled false (NUXT_PUBLIC_SEO_ENABLED=false).
# No resolve, no CMS head, no redirect, no root files, no 404 report: the
# pages keep their own head and the API sees no request at all.
200 /about contains=<title>Own about | Playground</title> contains=href="https://own-canonical.test/about" noheader=X-Robots-Tag
200 /plain?page=abc contains=<title>Plain page | Playground</title>
404 /stary noheader=Location
404 /robots.txt
404 /google1234.html
404 /google0123.html
404 /kontakt
apicalls 0
noreport /kontakt

View File

@@ -39,3 +39,27 @@ resolves /stary 1
200 /api/__seo__/resolve?url=/blog/lwia-grzywa contains="title":"Lwia grzywa | Example" header=X-Robots-Tag: noindex
resolves /blog/lwia-grzywa 1
422 /api/__seo__/resolve?url=https://evil.test/
# Root files from the backend, with its status, content type and
# X-Robots-Tag; a backend failure is 503 with Retry-After.
200 /robots.txt contains=Disallow: /konto type=text/plain header=X-Content-Type-Options: nosniff
200 /sitemap.xml contains=<sitemapindex type=application/xml
200 /sitemap-posts-1.xml contains=<urlset type=application/xml header=X-Robots-Tag: noindex
200 /llms.txt contains=Fixture llms.txt header=X-Robots-Tag: noindex
404 /sitemap-missing-1.xml
503 /sitemap-fail-1.xml header=Retry-After: 60
# Listed root files: a verification file and the IndexNow key file.
200 /google1234.html contains=google-site-verification: google1234.html header=X-Robots-Tag: noindex header=X-Content-Type-Options: nosniff
200 /a1b2c3d4e5f60718293a4b5c6d7e8f90.txt contains=a1b2c3d4e5f60718293a4b5c6d7e8f90 type=text/plain
# Not listed: passes through to Nuxt, which renders its 404 (the route
# middleware resolves it; no verification lookup is made).
404 /random.txt
404 /google0123.html
404 /kontakt
# 404 pages report themselves once with the bearer token; a 410 or a
# redirect does not.
report /kontakt
report /nieznana-strona
noreport /usuniety
noreport /stary

View File

@@ -0,0 +1 @@
a1b2c3d4e5f60718293a4b5c6d7e8f90

View File

@@ -0,0 +1,3 @@
# Example
> Fixture llms.txt

View File

@@ -0,0 +1,3 @@
User-agent: *
Disallow: /konto
Sitemap: https://example.test/sitemap.xml

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.test/blog/lwia-grzywa</loc></url></urlset>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.test/sitemap-posts-1.xml</loc></sitemap></sitemapindex>

View File

@@ -0,0 +1,13 @@
{
"site_url": "https://example.test",
"default_locale": "pl",
"locales": ["pl", "en"],
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa-strony", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [{ "type": "post", "pattern": "/blog/:slug" }],
"root_files": ["google1234.html", "a1b2c3d4e5f60718293a4b5c6d7e8f90.txt"],
"llms": true,
"pagination": { "form": "query", "param": "page", "segments": {} },
"home_crumb": { "name": "Start", "url": "https://example.test/" }
}

View File

@@ -0,0 +1 @@
google-site-verification: google1234.html

72
tests/rootFiles.test.ts Normal file
View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { rootFileTargets, siteState, type SeoSiteState } from '../server/lib/site.ts'
import { seoBackendBase, seoServerConfig } from '../server/lib/config.ts'
const B = 'http://127.0.0.1:8096'
const A = 'http://127.0.0.1:8096/_seo/api/v1'
const KEY = 'a1b2c3d4e5f60718293a4b5c6d7e8f90.txt'
const ok: SeoSiteState = { state: 'ok', site: { root_files: ['google1234.html', KEY, 'BingSiteAuth.xml'] } }
test('rootFileTargets: robots.txt, sitemap.xml, llms.txt and sitemap files go to the backend', () => {
assert.deepEqual(rootFileTargets('/robots.txt', ok, B, A), [{ kind: 'root', url: `${B}/robots.txt` }])
assert.deepEqual(rootFileTargets('/sitemap.xml?x=1', ok, B, A), [{ kind: 'root', url: `${B}/sitemap.xml` }])
assert.deepEqual(rootFileTargets('/llms.txt', { state: 'unknown' }, B, A), [{ kind: 'root', url: `${B}/llms.txt` }])
assert.deepEqual(rootFileTargets('/sitemap-posts-1.xml', ok, B, A), [{ kind: 'sitemap', url: `${B}/_sitemap/files/sitemap-posts-1.xml` }])
})
test('rootFileTargets: an absent site answer (older backend) serves none of them', () => {
for (const p of ['/robots.txt', '/sitemap.xml', '/llms.txt', '/sitemap-posts-1.xml', '/google1234.html']) {
assert.equal(rootFileTargets(p, { state: 'absent' }, B, A), null, p)
}
})
test('rootFileTargets: listed root files only, verification first then the IndexNow key', () => {
assert.deepEqual(rootFileTargets('/google1234.html', ok, B, A), [{ kind: 'verification', url: `${A}/verification/google1234.html` }])
assert.deepEqual(rootFileTargets(`/${KEY}`, ok, B, A), [
{ kind: 'verification', url: `${A}/verification/${KEY}` },
{ kind: 'indexnow', url: `${B}/_sitemap/indexnow/${KEY}` },
])
for (const p of ['/random.txt', '/kontakt', '/google1234.html/x', '/a/google1234.html', '/GOOGLE1234.HTML']) {
assert.equal(rootFileTargets(p, ok, B, A), null, p)
}
assert.equal(rootFileTargets('/google1234.html', { state: 'unknown' }, B, A), null)
})
test('rootFileTargets: no request text beyond an allow-listed name reaches the URL', () => {
for (const p of ['/sitemap-../../x.xml', '/sitemap-%2e%2e.xml', '/robots.txt/../admin', '//evil.test/robots.txt', '/sitemap-A.xml']) {
assert.equal(rootFileTargets(p, ok, B, A), null, p)
}
})
test('siteState: 200 object ok, 404 absent, anything else unknown', () => {
assert.equal(siteState(200, { root_files: [] }).state, 'ok')
assert.equal(siteState(404, { error: 'not_found' }).state, 'absent')
for (const [s, b] of [[500, {}], [200, 'x'], [200, null], [null, null], [200, []]] as const) assert.equal(siteState(s, b).state, 'unknown')
})
test('seoBackendBase: explicit, derived from apiBase, or its origin', () => {
assert.equal(seoBackendBase('http://go:9000/', A), 'http://go:9000')
assert.equal(seoBackendBase('', A), B)
assert.equal(seoBackendBase('', 'http://go:9000/_seo/api/v1/'), 'http://go:9000')
assert.equal(seoBackendBase('', 'http://go:9000/seo'), 'http://go:9000')
assert.equal(seoBackendBase('', 'not a url'), '')
})
test('seoServerConfig: defaults, aliases and tokens', () => {
const d = seoServerConfig({})
assert.equal(d.enabled, true)
assert.equal(d.apiBase, A)
assert.equal(d.backendBase, B)
assert.equal(d.timeoutMs, 1500)
assert.equal(d.resolveCacheTtl, 60)
assert.equal(d.rootFiles, true)
assert.deepEqual(d.resolveFiles, ['pdf'])
assert.equal(seoServerConfig({ public: { seo: { enabled: false } } }).enabled, false)
assert.equal(seoServerConfig({ public: { seo: { cms: 'false' } } }).enabled, false)
assert.equal(seoServerConfig({ public: { seo: { rootFiles: false } } }).rootFiles, false)
assert.equal(seoServerConfig({ seo: { resolveCacheTtl: '0' } }).resolveCacheTtl, 0)
assert.equal(seoServerConfig({ seo: { timeoutMs: '2500' } }).timeoutMs, 2500)
assert.equal(seoServerConfig({ seo: { notfoundToken: 't' } }).routeReportToken, 't')
assert.equal(seoServerConfig({ seo: { notfoundToken: 't', routeReportToken: 'r' } }).routeReportToken, 'r')
})