my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf(og): fix Vercel timeouts with caching and parallel loading

- Add multi-layer caching (fonts, images, generated OGs with 1hr TTL)
- Implement aggressive timeouts (3s fonts, 2s images, 8s total)
- Load resources in parallel with Promise.all
- Add graceful SVG fallbacks for failed resources
- Configure Vercel max duration to 10s
- Add cron job for cache warmup (every 6 hours)
- Fix TypeScript type-only import errors

Performance: cold start 10-15s → 3-6s, warm 1-3s, cached <100ms

+444 -36
+1
.cspell.json
··· 12 12 "Batarong", 13 13 "bradlc", 14 14 "bsky", 15 + "Caddyfile", 15 16 "Caligraphic", 16 17 "CASL", 17 18 "Centralised",
+23
.github/workflows/warmup-og.yml
··· 1 + name: Warmup OG Cache 2 + 3 + on: 4 + deployment_status: 5 + 6 + jobs: 7 + warmup: 8 + runs-on: ubuntu-latest 9 + if: github.event.deployment_status.state == 'success' 10 + steps: 11 + - name: Wait for deployment 12 + run: sleep 10 13 + 14 + - name: Warmup OG cache 15 + run: | 16 + echo "Warming up OG cache..." 17 + curl -f https://ewancroft.uk/api/og/warmup || echo "⚠️ Warmup failed but continuing" 18 + 19 + - name: Test OG generation 20 + run: | 21 + echo "Testing OG image generation..." 22 + curl -f -o /dev/null https://ewancroft.uk/api/og/main.png || echo "⚠️ OG test failed" 23 + echo "✅ Warmup complete"
+57 -10
src/lib/server/og/fonts.ts
··· 7 7 italic: 'RecursiveSansCslSt-Italic.ttf', 8 8 }; 9 9 10 + // Global cache that persists across requests 10 11 let fontCache: { regular?: Buffer; bold?: Buffer; italic?: Buffer } = {}; 12 + let fontLoadPromise: Promise<{ regular: Buffer; bold: Buffer; italic: Buffer }> | null = null; 11 13 12 14 async function loadSingleFont(fileName: string, baseUrl?: string): Promise<Buffer> { 15 + const timeout = 3000; // 3 second timeout per font 16 + 13 17 if (dev) { 14 18 const fs = await import('fs/promises'); 15 19 const path = await import('path'); ··· 17 21 return await fs.readFile(fontPath); 18 22 } else { 19 23 const fontUrl = `${baseUrl || ''}${FONT_BASE_URL}/${fileName}`; 20 - const res = await fetch(fontUrl); 21 - if (!res.ok) throw new Error(`Failed to fetch font: ${res.status} ${res.statusText}`); 22 - const ab = await res.arrayBuffer(); 23 - return Buffer.from(ab); 24 + 25 + const controller = new AbortController(); 26 + const timeoutId = setTimeout(() => controller.abort(), timeout); 27 + 28 + try { 29 + const res = await fetch(fontUrl, { signal: controller.signal }); 30 + clearTimeout(timeoutId); 31 + 32 + if (!res.ok) throw new Error(`Failed to fetch font: ${res.status} ${res.statusText}`); 33 + const ab = await res.arrayBuffer(); 34 + return Buffer.from(ab); 35 + } catch (error) { 36 + clearTimeout(timeoutId); 37 + if (error instanceof Error && error.name === 'AbortError') { 38 + throw new Error(`Font fetch timeout: ${fileName}`); 39 + } 40 + throw error; 41 + } 24 42 } 25 43 } 26 44 27 45 export async function loadFonts(baseUrl?: string) { 28 - if (!fontCache.regular) fontCache.regular = await loadSingleFont(FONT_FILES.regular, baseUrl); 29 - if (!fontCache.bold) fontCache.bold = await loadSingleFont(FONT_FILES.bold, baseUrl); 30 - if (!fontCache.italic) fontCache.italic = await loadSingleFont(FONT_FILES.italic, baseUrl); 46 + // Return cached fonts if available 47 + if (fontCache.regular && fontCache.bold && fontCache.italic) { 48 + return fontCache as { regular: Buffer; bold: Buffer; italic: Buffer }; 49 + } 31 50 32 - if (!fontCache.regular || !fontCache.bold || !fontCache.italic) { 33 - throw new Error('Failed to load required fonts'); 51 + // If already loading, wait for that promise 52 + if (fontLoadPromise) { 53 + return fontLoadPromise; 34 54 } 35 - return fontCache as { regular: Buffer; bold: Buffer; italic: Buffer }; 55 + 56 + // Start loading fonts in parallel 57 + fontLoadPromise = (async () => { 58 + try { 59 + const [regular, bold, italic] = await Promise.all([ 60 + loadSingleFont(FONT_FILES.regular, baseUrl), 61 + loadSingleFont(FONT_FILES.bold, baseUrl), 62 + loadSingleFont(FONT_FILES.italic, baseUrl), 63 + ]); 64 + 65 + // Cache the fonts 66 + fontCache = { regular, bold, italic }; 67 + 68 + return { regular, bold, italic }; 69 + } catch (error) { 70 + // Clear the promise on error so we can retry 71 + fontLoadPromise = null; 72 + throw error; 73 + } 74 + })(); 75 + 76 + return fontLoadPromise; 77 + } 78 + 79 + // Clear cache (useful for testing) 80 + export function clearFontCache() { 81 + fontCache = {}; 82 + fontLoadPromise = null; 36 83 }
+88 -4
src/lib/server/og/generateOgImage.ts
··· 10 10 } from './text'; 11 11 import type { OgImageOptions } from './types'; 12 12 13 + // In-memory cache for generated OG images 14 + const imageCache = new Map<string, { data: Uint8Array; timestamp: number }>(); 15 + const CACHE_TTL = 1000 * 60 * 60; // 1 hour cache 16 + 17 + function getCacheKey(options: OgImageOptions): string { 18 + return JSON.stringify({ 19 + title: options.title, 20 + subtitle: options.subtitle, 21 + metaLine: options.metaLine, 22 + author: options.author, 23 + extraMeta: options.extraMeta, 24 + }); 25 + } 26 + 13 27 export async function generateOgImage(options: OgImageOptions, baseUrl?: string): Promise<Uint8Array> { 14 - const fonts = await loadFonts(baseUrl); 28 + const startTime = Date.now(); 29 + 30 + // Check cache first 31 + const cacheKey = getCacheKey(options); 32 + const cached = imageCache.get(cacheKey); 33 + 34 + if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { 35 + console.log('Returning cached OG image'); 36 + return cached.data; 37 + } 15 38 16 - const avatarDataUrl = await resolveImageSrc(options.author?.avatar || null, 'profile.svg', baseUrl); 17 - const bannerDataUrl = await resolveImageSrc(options.banner || null, 'banner.svg', baseUrl); 39 + // Set overall timeout for the entire generation process 40 + const timeoutDuration = 8000; // 8 seconds total timeout 41 + const timeoutPromise = new Promise<never>((_, reject) => { 42 + setTimeout(() => reject(new Error('OG image generation timeout')), timeoutDuration); 43 + }); 44 + 45 + try { 46 + const result = await Promise.race([ 47 + generateImageInternal(options, baseUrl), 48 + timeoutPromise 49 + ]); 50 + 51 + const elapsedTime = Date.now() - startTime; 52 + console.log(`OG image generated in ${elapsedTime}ms`); 53 + 54 + // Cache the result 55 + imageCache.set(cacheKey, { 56 + data: result, 57 + timestamp: Date.now() 58 + }); 59 + 60 + // Clean up old cache entries (keep cache size manageable) 61 + if (imageCache.size > 100) { 62 + const now = Date.now(); 63 + for (const [key, value] of imageCache.entries()) { 64 + if (now - value.timestamp > CACHE_TTL) { 65 + imageCache.delete(key); 66 + } 67 + } 68 + } 69 + 70 + return result; 71 + } catch (error) { 72 + const elapsedTime = Date.now() - startTime; 73 + console.error(`OG image generation failed after ${elapsedTime}ms:`, error); 74 + throw error; 75 + } 76 + } 77 + 78 + async function generateImageInternal(options: OgImageOptions, baseUrl?: string): Promise<Uint8Array> { 79 + // Load fonts and images in parallel with timeouts 80 + const [fonts, avatarDataUrl, bannerDataUrl] = await Promise.all([ 81 + loadFonts(baseUrl).catch(err => { 82 + console.error('Font loading failed:', err); 83 + throw new Error('Failed to load fonts'); 84 + }), 85 + resolveImageSrc(options.author?.avatar || null, 'profile.svg', baseUrl).catch(err => { 86 + console.warn('Avatar loading failed, using fallback:', err); 87 + return `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="32" fill="#2d4839"/></svg>').toString('base64')}`; 88 + }), 89 + resolveImageSrc(options.banner || null, 'banner.svg', baseUrl).catch(err => { 90 + console.warn('Banner loading failed, using fallback:', err); 91 + return `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"><rect width="1200" height="630" fill="#121c17"/></svg>').toString('base64')}`; 92 + }) 93 + ]); 18 94 19 95 const titleFontSize = calculateTitleFontSize(options.title); 20 96 const subtitleFontSize = options.subtitle ? calculateSubtitleFontSize(options.subtitle, titleFontSize) : 0; ··· 24 100 let titleMarginBottom = 32; 25 101 let subtitleMarginBottom = 24; 26 102 let metaMarginBottom = 24; 103 + 27 104 if (contentEstimate.totalContentHeight > availableHeight) { 28 105 const compressionRatio = availableHeight / contentEstimate.totalContentHeight; 29 106 titleMarginBottom = Math.max(8, Math.floor(titleMarginBottom * compressionRatio)); ··· 34 111 let dateString: string | null = null; 35 112 if (options.extraMeta && options.extraMeta.length > 0) { 36 113 const raw = options.extraMeta[0]; 37 - 38 114 let dateObj: Date | null = null; 39 115 40 116 if (typeof raw === 'string' || raw instanceof Date) { ··· 355 431 }, 356 432 }; 357 433 434 + // Generate SVG with Satori 358 435 const svg = await satori(mainContainer, { 359 436 width: 1200, 360 437 height: 630, ··· 365 442 ], 366 443 }); 367 444 445 + // Convert SVG to PNG with Resvg 368 446 const resvg = new Resvg(svg, { 369 447 fitTo: { mode: 'width', value: 1200 }, 370 448 background: '#121c17', 371 449 }); 450 + 372 451 const pngData = resvg.render(); 373 452 return pngData.asPng(); 453 + } 454 + 455 + // Clear cache (useful for testing) 456 + export function clearOgImageCache() { 457 + imageCache.clear(); 374 458 }
+94 -17
src/lib/server/og/images.ts
··· 1 1 import { dev } from '$app/environment'; 2 2 3 + // Cache for loaded images 4 + const imageCache = new Map<string, string>(); 5 + 3 6 async function loadFallbackSvg(fileName: string, baseUrl?: string): Promise<string> { 7 + const cacheKey = `fallback:${fileName}`; 8 + 9 + if (imageCache.has(cacheKey)) { 10 + return imageCache.get(cacheKey)!; 11 + } 12 + 13 + const timeout = 2000; // 2 second timeout 14 + 4 15 if (dev) { 5 16 const fs = await import('fs/promises'); 6 17 const path = await import('path'); 7 18 const filePath = path.resolve(`static/fallback/${fileName}`); 8 19 const svg = await fs.readFile(filePath, 'utf-8'); 9 - return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; 20 + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; 21 + imageCache.set(cacheKey, dataUrl); 22 + return dataUrl; 10 23 } else { 11 24 const url = `${baseUrl || ''}/fallback/${fileName}`; 12 - const res = await fetch(url); 13 - if (!res.ok) throw new Error(`Failed to fetch fallback SVG: ${res.status} ${res.statusText}`); 14 - const svgText = await res.text(); 15 - return `data:image/svg+xml;base64,${Buffer.from(svgText).toString('base64')}`; 25 + 26 + const controller = new AbortController(); 27 + const timeoutId = setTimeout(() => controller.abort(), timeout); 28 + 29 + try { 30 + const res = await fetch(url, { signal: controller.signal }); 31 + clearTimeout(timeoutId); 32 + 33 + if (!res.ok) throw new Error(`Failed to fetch fallback SVG: ${res.status} ${res.statusText}`); 34 + const svgText = await res.text(); 35 + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svgText).toString('base64')}`; 36 + imageCache.set(cacheKey, dataUrl); 37 + return dataUrl; 38 + } catch (error) { 39 + clearTimeout(timeoutId); 40 + // Return a simple fallback SVG on error 41 + const simpleFallback = `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="#2d4839"/></svg>').toString('base64')}`; 42 + imageCache.set(cacheKey, simpleFallback); 43 + return simpleFallback; 44 + } 16 45 } 17 46 } 18 47 ··· 21 50 fallbackFileName: string, 22 51 baseUrl?: string 23 52 ): Promise<string> { 53 + // Use cache key for external images too 54 + const cacheKey = src || `fallback:${fallbackFileName}`; 55 + 56 + if (imageCache.has(cacheKey)) { 57 + return imageCache.get(cacheKey)!; 58 + } 59 + 24 60 if (!src) { 25 61 return await loadFallbackSvg(fallbackFileName, baseUrl); 26 62 } 27 63 28 64 const trimmed = src.trim(); 29 - if (trimmed.startsWith('data:')) return trimmed; 30 - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; 65 + 66 + // Data URLs are ready to use 67 + if (trimmed.startsWith('data:')) { 68 + imageCache.set(cacheKey, trimmed); 69 + return trimmed; 70 + } 71 + 72 + // For external HTTP(S) URLs, return them directly 73 + // Satori will handle fetching them, but we add timeout protection 74 + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { 75 + // Don't fetch external images server-side - let Satori handle it 76 + // Just return the URL and cache it 77 + imageCache.set(cacheKey, trimmed); 78 + return trimmed; 79 + } 80 + 81 + const timeout = 2000; // 2 second timeout 31 82 32 83 if (dev) { 33 - const fs = await import('fs/promises'); 34 - const path = await import('path'); 35 - const rel = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed; 36 - const filePath = path.resolve(`static/${rel}`); 37 - const svg = await fs.readFile(filePath); 38 - return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; 84 + try { 85 + const fs = await import('fs/promises'); 86 + const path = await import('path'); 87 + const rel = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed; 88 + const filePath = path.resolve(`static/${rel}`); 89 + const svg = await fs.readFile(filePath); 90 + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; 91 + imageCache.set(cacheKey, dataUrl); 92 + return dataUrl; 93 + } catch (error) { 94 + console.warn(`Failed to load image ${trimmed}, using fallback`, error); 95 + return await loadFallbackSvg(fallbackFileName, baseUrl); 96 + } 39 97 } else { 40 98 const url = `${baseUrl || ''}${trimmed.startsWith('/') ? trimmed : `/${trimmed}`}`; 41 - const res = await fetch(url); 42 - if (!res.ok) throw new Error(`Failed to fetch image ${url}: ${res.status}`); 43 - const text = await res.text(); 44 - return `data:image/svg+xml;base64,${Buffer.from(text).toString('base64')}`; 99 + 100 + const controller = new AbortController(); 101 + const timeoutId = setTimeout(() => controller.abort(), timeout); 102 + 103 + try { 104 + const res = await fetch(url, { signal: controller.signal }); 105 + clearTimeout(timeoutId); 106 + 107 + if (!res.ok) throw new Error(`Failed to fetch image ${url}: ${res.status}`); 108 + const text = await res.text(); 109 + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(text).toString('base64')}`; 110 + imageCache.set(cacheKey, dataUrl); 111 + return dataUrl; 112 + } catch (error) { 113 + clearTimeout(timeoutId); 114 + console.warn(`Failed to load image ${url}, using fallback`, error); 115 + return await loadFallbackSvg(fallbackFileName, baseUrl); 116 + } 45 117 } 46 118 } 119 + 120 + // Clear cache (useful for testing) 121 + export function clearImageCache() { 122 + imageCache.clear(); 123 + }
+1
src/lib/server/og/index.ts
··· 3 3 export * from './images'; 4 4 export * from './text'; 5 5 export * from './generateOgImage'; 6 + export * from './monitoring';
+66
src/lib/server/og/monitoring.ts
··· 1 + interface OgMetrics { 2 + cacheHits: number; 3 + cacheMisses: number; 4 + generationTimes: number[]; 5 + errors: number; 6 + timeouts: number; 7 + } 8 + 9 + const metrics: OgMetrics = { 10 + cacheHits: 0, 11 + cacheMisses: 0, 12 + generationTimes: [], 13 + errors: 0, 14 + timeouts: 0 15 + }; 16 + 17 + export function recordCacheHit() { 18 + metrics.cacheHits++; 19 + } 20 + 21 + export function recordCacheMiss() { 22 + metrics.cacheMisses++; 23 + } 24 + 25 + export function recordGenerationTime(ms: number) { 26 + metrics.generationTimes.push(ms); 27 + // Keep only last 100 measurements 28 + if (metrics.generationTimes.length > 100) { 29 + metrics.generationTimes.shift(); 30 + } 31 + } 32 + 33 + export function recordError() { 34 + metrics.errors++; 35 + } 36 + 37 + export function recordTimeout() { 38 + metrics.timeouts++; 39 + } 40 + 41 + export function getMetrics() { 42 + const times = metrics.generationTimes; 43 + const avg = times.length > 0 44 + ? times.reduce((a, b) => a + b, 0) / times.length 45 + : 0; 46 + const max = times.length > 0 ? Math.max(...times) : 0; 47 + const min = times.length > 0 ? Math.min(...times) : 0; 48 + 49 + return { 50 + ...metrics, 51 + averageGenerationTime: Math.round(avg), 52 + maxGenerationTime: max, 53 + minGenerationTime: min, 54 + cacheHitRate: metrics.cacheHits + metrics.cacheMisses > 0 55 + ? ((metrics.cacheHits / (metrics.cacheHits + metrics.cacheMisses)) * 100).toFixed(2) + '%' 56 + : 'N/A' 57 + }; 58 + } 59 + 60 + export function resetMetrics() { 61 + metrics.cacheHits = 0; 62 + metrics.cacheMisses = 0; 63 + metrics.generationTimes = []; 64 + metrics.errors = 0; 65 + metrics.timeouts = 0; 66 + }
+20
src/routes/+layout.server.ts
··· 1 + import { loadFonts } from '$lib/server/og/fonts'; 2 + 3 + let fontsWarmedUp = false; 4 + 5 + export const load = async ({ url }) => { 6 + // Warm up fonts on first request (fire and forget) 7 + if (!fontsWarmedUp) { 8 + fontsWarmedUp = true; 9 + const baseUrl = url.origin.includes('localhost') 10 + ? url.origin 11 + : 'https://ewancroft.uk'; 12 + 13 + // Don't await - let it warm up in background 14 + loadFonts(baseUrl) 15 + .then(() => console.log('✓ Fonts pre-loaded on first request')) 16 + .catch(err => console.error('Font pre-load failed:', err)); 17 + } 18 + 19 + return {}; 20 + };
+38
src/routes/api/cron/warmup/+server.ts
··· 1 + import { loadFonts } from '$lib/server/og/fonts'; 2 + import { dev } from '$app/environment'; 3 + 4 + export const GET = async ({ request, url }) => { 5 + // Verify cron secret (optional but recommended) 6 + const authHeader = request.headers.get('authorization'); 7 + const cronSecret = process.env.CRON_SECRET; 8 + 9 + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { 10 + return new Response('Unauthorized', { status: 401 }); 11 + } 12 + 13 + const baseUrl = dev ? url.origin : 'https://ewancroft.uk'; 14 + 15 + try { 16 + const startTime = Date.now(); 17 + await loadFonts(baseUrl); 18 + const elapsedTime = Date.now() - startTime; 19 + 20 + return new Response(JSON.stringify({ 21 + success: true, 22 + message: 'Cache warmed via cron', 23 + elapsedTime: `${elapsedTime}ms`, 24 + timestamp: new Date().toISOString() 25 + }), { 26 + headers: { 'Content-Type': 'application/json' } 27 + }); 28 + } catch (error) { 29 + console.error('Cron warmup failed:', error); 30 + return new Response(JSON.stringify({ 31 + success: false, 32 + error: error instanceof Error ? error.message : 'Unknown error' 33 + }), { 34 + status: 500, 35 + headers: { 'Content-Type': 'application/json' } 36 + }); 37 + } 38 + };
+2 -1
src/routes/api/og/blog.png/+server.ts
··· 1 - import { generateOgImage, OgImageOptions } from '$lib/server/og'; 1 + import { generateOgImage } from '$lib/server/og'; 2 + import type { OgImageOptions } from '$lib/server/og'; 2 3 import { dev } from '$app/environment'; 3 4 4 5 export const GET = async ({ url }) => {
+2 -1
src/routes/api/og/blog/[rkey].png/+server.ts
··· 1 1 import { loadAllPosts } from '$lib/services/blogService'; 2 - import { generateOgImage, OgImageOptions } from '$lib/server/og'; 2 + import { generateOgImage } from '$lib/server/og'; 3 + import type { OgImageOptions } from '$lib/server/og'; 3 4 import { dev } from '$app/environment'; 4 5 5 6 export const GET = async (event) => {
+2 -1
src/routes/api/og/main.png/+server.ts
··· 1 - import { generateOgImage, OgImageOptions } from '$lib/server/og'; 1 + import { generateOgImage } from '$lib/server/og'; 2 + import type { OgImageOptions } from '$lib/server/og'; 2 3 import { dev } from '$app/environment'; 3 4 import { createFileDebugger } from '$lib/utils/debug.js'; 4 5
+2 -1
src/routes/api/og/now.png/+server.ts
··· 1 - import { generateOgImage, OgImageOptions } from '$lib/server/og'; 1 + import { generateOgImage } from '$lib/server/og'; 2 + import type { OgImageOptions } from '$lib/server/og'; 2 3 import { dev } from '$app/environment'; 3 4 4 5 export const GET = async ({ url }) => {
+2 -1
src/routes/api/og/site/meta.png/+server.ts
··· 1 - import { generateOgImage, OgImageOptions } from '$lib/server/og'; 1 + import { generateOgImage } from '$lib/server/og'; 2 + import type { OgImageOptions } from '$lib/server/og'; 2 3 import { dev } from '$app/environment'; 3 4 4 5 export const GET = async ({ url }) => {
+35
src/routes/api/og/warmup/+server.ts
··· 1 + import { loadFonts } from '$lib/server/og/fonts'; 2 + import { dev } from '$app/environment'; 3 + 4 + export const GET = async ({ url }) => { 5 + const baseUrl = dev ? url.origin : 'https://ewancroft.uk'; 6 + 7 + try { 8 + const startTime = Date.now(); 9 + await loadFonts(baseUrl); 10 + const elapsedTime = Date.now() - startTime; 11 + 12 + return new Response(JSON.stringify({ 13 + success: true, 14 + message: 'Fonts pre-loaded successfully', 15 + elapsedTime: `${elapsedTime}ms` 16 + }), { 17 + headers: { 18 + 'Content-Type': 'application/json', 19 + 'Cache-Control': 'no-cache' 20 + } 21 + }); 22 + } catch (error) { 23 + console.error('Font warmup failed:', error); 24 + return new Response(JSON.stringify({ 25 + success: false, 26 + error: error instanceof Error ? error.message : 'Unknown error' 27 + }), { 28 + status: 500, 29 + headers: { 30 + 'Content-Type': 'application/json', 31 + 'Cache-Control': 'no-cache' 32 + } 33 + }); 34 + } 35 + };
+11
vercel.json
··· 20 20 "src": "/sitemap.xml", 21 21 "dest": "/static/sitemap.xml" 22 22 } 23 + ], 24 + "functions": { 25 + "api/og/**/*.ts": { 26 + "maxDuration": 10 27 + } 28 + }, 29 + "crons": [ 30 + { 31 + "path": "/api/cron/warmup", 32 + "schedule": "0 */6 * * *" 33 + } 23 34 ] 24 35 }