SEO prerender pipeline + register AI routes and tighten CSP
CI / build-and-test (push) Has been cancelled

- Static prerender + sitemap/robots for marketing, blog, and tool pages
  (entry-server, Seo component, prerender/sitemap scripts, NotFoundPage)
- Register aiRoutes and extend CSP for Turnstile + Spaces image hosts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-17 13:37:05 -04:00
co-authored by Claude Fable 5
parent 304f7f30c3
commit 1f249dc126
16 changed files with 690 additions and 40 deletions
+55
View File
@@ -135,17 +135,72 @@ export async function buildServer() {
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when
// @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a
// non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
// Only paths WITH a file extension are served by the plugin (hashed assets, favicons,
// sitemap.xml, ...). Page-shaped requests — '/', '/blog/<slug>', '/blog/<slug>/' — fall
// through to the not-found handler below (allowedPath:false → reply.callNotFound()),
// which serves the matching prerendered index.html or the app shell with
// Cache-Control: no-cache. Without this, the plugin's directory-index handling serves
// prerendered HTML itself, stamped with the 1y immutable header above — meant only for
// hashed assets — so browsers/crawlers would pin a year-stale page after every deploy.
index: false,
allowedPath: (pathName) => path.extname(pathName) !== '',
});
// Public SPA route prefixes — keep in sync with apps/web/src/App.tsx routes.
const KNOWN_SPA_PREFIXES = ['/login', '/signup', '/forgot-password', '/reset-password', '/billing/', '/tools', '/blog', '/legal', '/app', '/admin'];
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached
// for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
// Known SPA paths get 200; anything else (including missing static files) still gets
// index.html — so the client renders its NotFound page — but with a 404 status so
// crawlers don't index junk URLs as soft-200s.
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/')) {
return reply.code(404).send({ error: 'not_found' });
}
const pathname = (req.raw.url ?? '').split('?')[0] ?? '';
// Prerendered pages: an extensionless path like /blog/some-post misses @fastify/static
// (no trailing slash → no directory index lookup) and lands here. If the build produced
// dist/blog/some-post/index.html, serve THAT file — crawlers must get the route-specific
// head tags, not the root app shell. Decode + resolve and require the result to stay
// inside webDist so encoded traversal (/..%2f..) can never escape the dist root.
let decodedPath: string | null = null;
try {
decodedPath = decodeURIComponent(pathname);
} catch {
decodedPath = null; // malformed percent-encoding → fall through to the SPA fallback
}
if (decodedPath && !decodedPath.includes('\0')) {
const relDir = decodedPath.replace(/^\/+/, '').replace(/\/+$/, '');
const distRoot = path.resolve(webDist);
const candidate = path.resolve(distRoot, relDir, 'index.html');
if (
relDir.length > 0 &&
candidate.startsWith(distRoot + path.sep) &&
fs.existsSync(candidate)
) {
return reply
.code(200)
.header('Cache-Control', 'no-cache')
.type('text/html')
.sendFile(path.relative(distRoot, candidate).split(path.sep).join('/'), webDist, {
cacheControl: false,
});
}
}
const isKnown =
pathname === '/' ||
KNOWN_SPA_PREFIXES.some((prefix) =>
prefix.endsWith('/')
? pathname.startsWith(prefix)
: pathname === prefix || pathname.startsWith(`${prefix}/`),
);
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header
// (which would otherwise override the no-cache below and pin a stale app shell).
return reply
.code(isKnown ? 200 : 404)
.header('Cache-Control', 'no-cache')
.type('text/html')
.sendFile('index.html', webDist, { cacheControl: false });