SEO prerender pipeline + register AI routes and tighten CSP
CI / build-and-test (push) Has been cancelled
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:
co-authored by
Claude Fable 5
parent
304f7f30c3
commit
1f249dc126
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user