diff --git a/migrations/05_add_multi_domain_routing.sql b/migrations/05_add_multi_domain_routing.sql new file mode 100644 index 0000000..95e5cbb --- /dev/null +++ b/migrations/05_add_multi_domain_routing.sql @@ -0,0 +1,52 @@ +-- Migration: Add routing columns and performance indexes +-- This enables multi-domain routing with <5ms lookup performance + +-- Add route column and SEO fields to pages +ALTER TABLE pages +ADD COLUMN IF NOT EXISTS route VARCHAR(512), +ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'draft', +ADD COLUMN IF NOT EXISTS meta_title VARCHAR(255), +ADD COLUMN IF NOT EXISTS meta_description VARCHAR(512), +ADD COLUMN IF NOT EXISTS seo_data JSONB DEFAULT '{}', +ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ; + +-- Migrate existing slugs to routes +UPDATE pages SET route = '/' || slug WHERE route IS NULL; + +-- Make route required +ALTER TABLE pages ALTER COLUMN route SET NOT NULL; + +-- ⚡ CRITICAL: Performance indexes for <5ms routing +CREATE INDEX IF NOT EXISTS idx_pages_site_route ON pages (site_id, route); + +CREATE INDEX IF NOT EXISTS idx_sites_domain_active ON sites (domain) +WHERE + status = 'active'; + +-- Unique constraint per site +ALTER TABLE pages +ADD CONSTRAINT unique_site_route UNIQUE (site_id, route); + +-- Add status index for published pages +CREATE INDEX IF NOT EXISTS idx_pages_status ON pages (status) +WHERE + status = 'published'; + +-- Add route index +CREATE INDEX IF NOT EXISTS idx_pages_route ON pages (route); + +-- Same SEO fields for posts +ALTER TABLE posts +ADD COLUMN IF NOT EXISTS seo_data JSONB DEFAULT '{}'; + +-- Performance index for posts +CREATE INDEX IF NOT EXISTS idx_posts_site_slug ON posts (site_id, slug); + +CREATE INDEX IF NOT EXISTS idx_posts_status ON posts (status) +WHERE + status = 'published'; + +-- Success message +DO $$ BEGIN RAISE NOTICE '✅ Multi-domain routing migration complete - Indexes created for <5ms performance'; + +END $$; \ No newline at end of file diff --git a/src/lib/cache/redis.ts b/src/lib/cache/redis.ts new file mode 100644 index 0000000..5761459 --- /dev/null +++ b/src/lib/cache/redis.ts @@ -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( + key: string, + fetchFn: () => Promise, + ttl: number = 60 +): Promise { + 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 = {}; + + 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' }; + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..bab07ba --- /dev/null +++ b/src/middleware.ts @@ -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(` + + + Site Not Found + +

🔍 Site Not Found

+

The domain ${host} is not configured.

+

+ Please contact support if you believe this is an error. +

+ + + `, { + status: 404, + headers: { 'Content-Type': 'text/html' } + }); + } + + if (site.status !== 'active') { + return new Response(` + + + Site Maintenance + +

🚧 Site Under Maintenance

+

We'll be back soon!

+ + + `, { + 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(` + + + Page Not Found + +

404 - Page Not Found

+

The page ${pathname} doesn't exist.

+ ← Back to Home + + + `, { + status: 404, + headers: { 'Content-Type': 'text/html' } + }); + + } catch (err) { + console.error('Middleware error:', err); + return new Response('Internal Server Error', { status: 500 }); + } +}); diff --git a/src/pages/render/page.astro b/src/pages/render/page.astro new file mode 100644 index 0000000..a400ce6 --- /dev/null +++ b/src/pages/render/page.astro @@ -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 || {}; +--- + + + + + + + + + {title} + + {keywords.length > 0 && } + + + + + + + + + + + {ogImage && ( + <> + + + + + )} + + + + + + + {ogImage && } + + + {seoData.schema && ( +