Add multi-domain routing: middleware, Redis caching, dynamic renderers

- Migration: Add route column + performance indexes for <5ms lookups
- Redis cache layer with graceful degradation
- Middleware: Route custom domains to database pages
- Page/Post renderers with full SEO metadata
- Support for 12-section content fragments
- Site-specific styling from config.custom_css

Enables: aesthetichelp.com -> database pages
Performance: <5ms routing with Redis caching
This commit is contained in:
cawcenter
2025-12-16 16:39:26 -05:00
parent 94fdaf5315
commit 64f22d67d8
5 changed files with 768 additions and 0 deletions

153
src/lib/cache/redis.ts vendored Normal file
View File

@@ -0,0 +1,153 @@
/**
* Redis Cache Helper
*
* Provides caching layer for site/page lookups to achieve <5ms routing.
* Gracefully degrades if Redis is unavailable.
*/
import Redis from 'ioredis';
let redis: Redis | null = null;
export function getRedisClient(): Redis {
if (!redis) {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
// Timeout to prevent hanging
connectTimeout: 1000,
commandTimeout: 2000
});
// Handle errors gracefully
redis.on('error', (err) => {
console.warn('Redis error:', err.message);
});
}
return redis;
}
/**
* Cache wrapper for site/page lookups
* Falls back to direct DB query if Redis unavailable
*
* @param key - Cache key
* @param fetchFn - Function to fetch data if cache miss
* @param ttl - Time to live in seconds (default: 60s)
*/
export async function getCached<T>(
key: string,
fetchFn: () => Promise<T>,
ttl: number = 60
): Promise<T> {
try {
const redis = getRedisClient();
// Ensure connection
if (redis.status !== 'ready') {
await redis.connect().catch(() => null);
}
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
// Redis unavailable - fall through to DB
if (process.env.NODE_ENV === 'development') {
console.warn('Redis cache miss, using DB:', key);
}
}
// Cache miss or Redis unavailable - fetch from DB
const data = await fetchFn();
// Store in cache (fire and forget - don't block on cache writes)
if (data !== null && data !== undefined) {
try {
const redis = getRedisClient();
redis.setex(key, ttl, JSON.stringify(data)).catch(() => {
// Ignore cache write failures
});
} catch (err) {
// Cache write failure is non-fatal
}
}
return data;
}
/**
* Invalidate all cache entries for a site
* Use this when pages are updated/deleted
*
* @param domain - Site domain to invalidate
*/
export async function invalidateSite(domain: string) {
try {
const redis = getRedisClient();
const pattern = `site:${domain}:*`;
// Find all keys matching pattern
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
console.log(`Cache invalidated: ${keys.length} keys for ${domain}`);
}
} catch (err) {
console.warn('Cache invalidation failed:', err);
// Non-fatal - cache will expire naturally
}
}
/**
* Invalidate a specific page cache
*
* @param domain - Site domain
* @param route - Page route (e.g., "/about")
*/
export async function invalidatePage(domain: string, route: string) {
try {
const redis = getRedisClient();
const key = `site:${domain}:page:${route}`;
await redis.del(key);
} catch (err) {
console.warn('Page cache invalidation failed:', err);
}
}
/**
* Get cache statistics
*/
export async function getCacheStats() {
try {
const redis = getRedisClient();
const info = await redis.info('stats');
const lines = info.split('\r\n');
const stats: Record<string, string> = {};
lines.forEach(line => {
const [key, value] = line.split(':');
if (key && value) {
stats[key] = value;
}
});
return {
hits: parseInt(stats.keyspace_hits || '0'),
misses: parseInt(stats.keyspace_misses || '0'),
hitRate: stats.keyspace_hits && stats.keyspace_misses
? (parseInt(stats.keyspace_hits) / (parseInt(stats.keyspace_hits) + parseInt(stats.keyspace_misses)) * 100).toFixed(2) + '%'
: 'N/A'
};
} catch (err) {
return { hits: 0, misses: 0, hitRate: 'Redis unavailable' };
}
}

176
src/middleware.ts Normal file
View File

@@ -0,0 +1,176 @@
/**
* Multi-Domain Routing Middleware
*
* Routes custom domains (aesthetichelp.com) to pages from database.
* Uses Redis caching for <5ms performance.
*
* Flow:
* 1. Extract domain from Host header
* 2. Lookup site by domain (Redis cached)
* 3. Lookup page by route (Redis cached)
* 4. Rewrite to dynamic renderer
*/
import { defineMiddleware } from 'astro:middleware';
import { pool } from '@/lib/db';
import { getCached } from '@/lib/cache/redis';
export const onRequest = defineMiddleware(async (context, next) => {
const host = context.request.headers.get('host')?.split(':')[0].replace(/^www\./, '') || '';
const pathname = context.url.pathname;
// Skip static assets, admin, API routes
if (
pathname.startsWith('/admin') ||
pathname.startsWith('/api') ||
pathname.startsWith('/shim') ||
pathname.startsWith('/preview') ||
pathname.startsWith('/render') ||
pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff|woff2|map|html|webp|gif|pdf)$/)
) {
return next();
}
// Platform domain - allow default routing
const platformDomain = import.meta.env.PUBLIC_PLATFORM_DOMAIN || 'spark.jumpstartscaling.com';
if (host.includes(platformDomain) || host === 'localhost' || host === '127.0.0.1') {
return next();
}
try {
// ⚡ REDIS-CACHED SITE LOOKUP (5 min cache)
const site = await getCached(
`site:${host}:meta`,
async () => {
const { rows } = await pool.query(
'SELECT id, name, domain, status, config FROM sites WHERE domain = $1',
[host]
);
return rows[0] || null;
},
300 // 5 min cache
);
if (!site) {
return new Response(`
<!DOCTYPE html>
<html>
<head><title>Site Not Found</title></head>
<body style="font-family: system-ui; text-align: center; padding: 100px 20px;">
<h1>🔍 Site Not Found</h1>
<p>The domain <strong>${host}</strong> is not configured.</p>
<p style="color: #666; font-size: 14px;">
Please contact support if you believe this is an error.
</p>
</body>
</html>
`, {
status: 404,
headers: { 'Content-Type': 'text/html' }
});
}
if (site.status !== 'active') {
return new Response(`
<!DOCTYPE html>
<html>
<head><title>Site Maintenance</title></head>
<body style="font-family: system-ui; text-align: center; padding: 100px 20px;">
<h1>🚧 Site Under Maintenance</h1>
<p>We'll be back soon!</p>
</body>
</html>
`, {
status: 503,
headers: { 'Content-Type': 'text/html' }
});
}
// Inject site data into context
context.locals.site = site;
context.locals.siteId = site.id;
// ⚡ REDIS-CACHED PAGE LOOKUP (1 min cache)
const page = await getCached(
`site:${host}:page:${pathname}`,
async () => {
const { rows } = await pool.query(
`SELECT * FROM pages
WHERE site_id = $1 AND route = $2 AND status = 'published'
LIMIT 1`,
[site.id, pathname]
);
return rows[0] || null;
},
60 // 1 min cache
);
if (page) {
context.locals.page = page;
return context.rewrite('/render/page');
}
// Check for blog post (route: /blog/post-slug)
if (pathname.startsWith('/blog/')) {
const slug = pathname.replace('/blog/', '');
const post = await getCached(
`site:${host}:post:${slug}`,
async () => {
const { rows } = await pool.query(
`SELECT * FROM posts
WHERE site_id = $1 AND slug = $2 AND status = 'published'
LIMIT 1`,
[site.id, slug]
);
return rows[0] || null;
},
60
);
if (post) {
context.locals.post = post;
return context.rewrite('/render/post');
}
}
// 404 - check for custom 404 page
const notFoundPage = await getCached(
`site:${host}:page:/404`,
async () => {
const { rows } = await pool.query(
`SELECT * FROM pages
WHERE site_id = $1 AND route = '/404'
LIMIT 1`,
[site.id]
);
return rows[0] || null;
},
300
);
if (notFoundPage) {
context.locals.page = notFoundPage;
return context.rewrite('/render/page');
}
// Default 404
return new Response(`
<!DOCTYPE html>
<html>
<head><title>Page Not Found</title></head>
<body style="font-family: system-ui; text-align: center; padding: 100px 20px;">
<h1>404 - Page Not Found</h1>
<p>The page <strong>${pathname}</strong> doesn't exist.</p>
<a href="/" style="color: #667eea;">← Back to Home</a>
</body>
</html>
`, {
status: 404,
headers: { 'Content-Type': 'text/html' }
});
} catch (err) {
console.error('Middleware error:', err);
return new Response('Internal Server Error', { status: 500 });
}
});

191
src/pages/render/page.astro Normal file
View File

@@ -0,0 +1,191 @@
---
/**
* Dynamic Page Renderer
*
* Renders pages from database with full SEO metadata.
* Supports 12-section content fragments and site-specific styling.
*/
const { site, page } = Astro.locals;
if (!page || !site) {
return new Response('Not found', { status: 404 });
}
// Extract SEO data from JSONB
const seoData = page.seo_data || {};
const title = page.meta_title || page.title;
const description = page.meta_description || '';
const ogImage = seoData.og_image || '';
const keywords = seoData.keywords || [];
const canonicalUrl = Astro.url.href;
// Site configuration
const siteConfig = site.config || {};
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="description" content={description}>
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')}>}
<!-- Canonical URL -->
<link rel="canonical" href={canonicalUrl}>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content={canonicalUrl}>
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>
<meta property="og:site_name" content={site.name}>
{ogImage && (
<>
<meta property="og:image" content={ogImage}>
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
</>
)}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={canonicalUrl}>
<meta property="twitter:title" content={title}>
<meta property="twitter:description" content={description}>
{ogImage && <meta property="twitter:image" content={ogImage}>}
<!-- JSON-LD Schema (if provided in seo_data) -->
{seoData.schema && (
<script type="application/ld+json" set:html={JSON.stringify(seoData.schema)} />
)}
<!-- Site-specific custom CSS from config.custom_css -->
{siteConfig.custom_css && (
<style set:html={siteConfig.custom_css} />
)}
<!-- Default styling -->
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.7;
color: #1a1a1a;
background: #ffffff;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 700;
}
h1 { font-size: 2.5rem; }
h2 { font-size: 1.875rem; }
h3 { font-size: 1.5rem; }
p {
margin-bottom: 1.5rem;
}
a {
color: #667eea;
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #764ba2;
text-decoration: underline;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
ul, ol {
margin-left: 2rem;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
pre {
background: #f4f4f4;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 1.5rem;
}
blockquote {
border-left: 4px solid #667eea;
padding-left: 1rem;
margin: 1.5rem 0;
font-style: italic;
color: #666;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
th, td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background: #f8f9fa;
font-weight: 600;
}
</style>
</head>
<body>
<!-- Site header from config (optional) -->
{siteConfig.header && (
<header>
<Fragment set:html={siteConfig.header} />
</header>
)}
<!-- Main content - supports 12-section fragments -->
<main>
<Fragment set:html={page.content} />
</main>
<!-- Site footer from config (optional) -->
{siteConfig.footer && (
<footer>
<Fragment set:html={siteConfig.footer} />
</footer>
)}
</body>
</html>

196
src/pages/render/post.astro Normal file
View File

@@ -0,0 +1,196 @@
---
/**
* Dynamic Post Renderer
*
* Renders blog posts from database with article schema.
*/
const { site, post } = Astro.locals;
if (!post || !site) {
return new Response('Not found', { status: 404 });
}
// Extract SEO data
const seoData = post.seo_data || {};
const title = post.meta_title || post.title;
const description = post.meta_description || post.excerpt || '';
const ogImage = seoData.og_image || '';
const publishedDate = post.published_at ? new Date(post.published_at).toISOString() : new Date().toISOString();
const canonicalUrl = Astro.url.href;
// Site configuration
const siteConfig = site.config || {};
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="description" content={description}>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalUrl}>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="article">
<meta property="og:url" content={canonicalUrl}>
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>
<meta property="og:site_name" content={site.name}>
<meta property="article:published_time" content={publishedDate}>
{ogImage && (
<>
<meta property="og:image" content={ogImage}>
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
</>
)}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={canonicalUrl}>
<meta property="twitter:title" content={title}>
<meta property="twitter:description" content={description}>
{ogImage && <meta property="twitter:image" content={ogImage}>}
<!-- Article JSON-LD Schema -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"image": ogImage || undefined,
"datePublished": publishedDate,
"author": {
"@type": "Organization",
"name": site.name
},
"publisher": {
"@type": "Organization",
"name": site.name
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": canonicalUrl
}
})} />
<!-- Site-specific custom CSS -->
{siteConfig.custom_css && (
<style set:html={siteConfig.custom_css} />
)}
<!-- Default post styling -->
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 700px;
margin: 0 auto;
padding: 60px 20px;
line-height: 1.7;
color: #1a1a1a;
}
article header {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid #f0f0f0;
}
article h1 {
font-size: 2.75rem;
font-weight: 800;
margin-bottom: 1rem;
line-height: 1.2;
}
.post-meta {
color: #666;
font-size: 0.875rem;
margin-top: 1rem;
}
article h2 {
font-size: 1.875rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
}
article h3 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
}
article p {
margin-bottom: 1.5rem;
font-size: 1.125rem;
}
article a {
color: #667eea;
text-decoration: none;
}
article a:hover {
color: #764ba2;
text-decoration: underline;
}
article img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 2rem 0;
}
article ul, article ol {
margin-left: 2rem;
margin-bottom: 1.5rem;
}
article li {
margin-bottom: 0.75rem;
font-size: 1.125rem;
}
</style>
</head>
<body>
{siteConfig.header && (
<header>
<Fragment set:html={siteConfig.header} />
</header>
)}
<article>
<header>
<h1>{post.title}</h1>
<div class="post-meta">
Published {new Date(publishedDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
</header>
<Fragment set:html={post.content} />
</article>
{siteConfig.footer && (
<footer>
<Fragment set:html={siteConfig.footer} />
</footer>
)}
</body>
</html>