Created
December 25, 2025 18:16
-
-
Save izakfilmalter/6fb2990c33c26907428fd27e56aa69a5 to your computer and use it in GitHub Desktop.
tanstack/image
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { createFileRoute } from '@tanstack/react-router' | |
| import sharp from 'sharp' | |
| import { config } from '../../vercel' | |
| // Convert Vercel's glob patterns to regex for validation | |
| const compilePattern = (pattern: string): RegExp => { | |
| // Escape regex special chars except * | |
| const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') | |
| // Convert glob * to regex .* | |
| const regex = escaped.replace(/\*/g, '.*') | |
| return new RegExp(`^${regex}$`) | |
| } | |
| // Validate URL against configured remote patterns | |
| const isAllowedUrl = (url: string): boolean => { | |
| try { | |
| const parsed = new URL(url) | |
| return ( | |
| config.images?.remotePatterns?.some((pattern) => { | |
| if (pattern.protocol && `${pattern.protocol}:` !== parsed.protocol) { | |
| return false | |
| } | |
| const hostnameRegex = compilePattern(pattern.hostname) | |
| return hostnameRegex.test(parsed.hostname) | |
| }) ?? false | |
| ) | |
| } catch { | |
| return false | |
| } | |
| } | |
| // Validate width against configured sizes | |
| const isAllowedSize = (width: number): boolean => config.images?.sizes?.includes(width) ?? false | |
| // Determine best output format based on Accept header and Vercel config | |
| const getBestFormat = (acceptHeader: string | null): 'avif' | 'webp' | 'jpeg' => { | |
| const formats = (config.images?.formats as Array<string> | undefined) ?? ['image/webp'] | |
| // Check for AVIF support (best compression) | |
| if (formats.includes('image/avif') && acceptHeader?.includes('image/avif')) { | |
| return 'avif' | |
| } | |
| // Check for WebP support (good compression, wide support) | |
| if (formats.includes('image/webp') && acceptHeader?.includes('image/webp')) { | |
| return 'webp' | |
| } | |
| // Fallback to JPEG | |
| return 'jpeg' | |
| } | |
| // Get content type for output format | |
| const getContentType = (format: 'avif' | 'webp' | 'jpeg'): string => { | |
| const contentTypes = { | |
| avif: 'image/avif', | |
| jpeg: 'image/jpeg', | |
| webp: 'image/webp', | |
| } | |
| return contentTypes[format] | |
| } | |
| // Note: Route path is /image because TanStack Router treats _ prefix as pathless layout. | |
| // The Vite proxy rewrites /_vercel/image -> /image in development. | |
| // In production, Vercel handles /_vercel/image at the edge. | |
| export const Route = createFileRoute('/_vercel/image')({ | |
| server: { | |
| handlers: { | |
| GET: async ({ request }) => { | |
| const url = new URL(request.url) | |
| const imageUrl = url.searchParams.get('url') | |
| const width = url.searchParams.get('w') | |
| const quality = Number.parseInt(url.searchParams.get('q') || '75', 10) | |
| // Validate required parameters | |
| if (!imageUrl) { | |
| return new Response('Missing url parameter', { status: 400 }) | |
| } | |
| if (!width) { | |
| return new Response('Missing w (width) parameter', { status: 400 }) | |
| } | |
| const widthNum = Number.parseInt(width, 10) | |
| if (Number.isNaN(widthNum)) { | |
| return new Response('Invalid width parameter', { status: 400 }) | |
| } | |
| // Validate against Vercel config | |
| if (!isAllowedUrl(imageUrl)) { | |
| return new Response(`URL not allowed by remotePatterns: ${imageUrl}`, { status: 400 }) | |
| } | |
| if (!isAllowedSize(widthNum)) { | |
| return new Response( | |
| `Width ${width} not in allowed sizes: ${config.images?.sizes?.join(', ')}`, | |
| { | |
| status: 400, | |
| }, | |
| ) | |
| } | |
| try { | |
| // Fetch the original image | |
| const response = await fetch(imageUrl, { | |
| headers: { | |
| // Request any image format | |
| Accept: 'image/*', | |
| // Some servers require a user agent | |
| 'User-Agent': 'PreachX-Image-Optimizer/1.0', | |
| }, | |
| }) | |
| if (!response.ok) { | |
| return new Response(`Failed to fetch image: ${response.status}`, { | |
| status: response.status, | |
| }) | |
| } | |
| // Get the image as a buffer | |
| const imageBuffer = Buffer.from(await response.arrayBuffer()) | |
| // Determine output format based on Accept header | |
| const acceptHeader = request.headers.get('Accept') | |
| const outputFormat = getBestFormat(acceptHeader) | |
| // Process with Sharp | |
| let sharpInstance = sharp(imageBuffer) | |
| // Auto-rotate based on EXIF orientation | |
| .rotate() | |
| // Resize to target width, maintain aspect ratio, don't enlarge | |
| .resize(widthNum, undefined, { | |
| fit: 'inside', | |
| withoutEnlargement: true, | |
| }) | |
| // Convert to target format with quality setting | |
| switch (outputFormat) { | |
| case 'avif': | |
| sharpInstance = sharpInstance.avif({ quality }) | |
| break | |
| case 'webp': | |
| sharpInstance = sharpInstance.webp({ quality }) | |
| break | |
| case 'jpeg': | |
| sharpInstance = sharpInstance.jpeg({ mozjpeg: true, quality }) | |
| break | |
| } | |
| const optimizedBuffer = await sharpInstance.toBuffer() | |
| // Convert Node Buffer to Uint8Array for Response compatibility | |
| return new Response(new Uint8Array(optimizedBuffer), { | |
| headers: { | |
| // In dev, don't cache in browser to allow easy refreshing | |
| 'Cache-Control': 'public, max-age=0, must-revalidate', | |
| 'Content-Type': getContentType(outputFormat), | |
| // Debug headers | |
| 'X-Image-Optimized': 'true', | |
| 'X-Original-Url': imageUrl, | |
| 'X-Output-Format': outputFormat, | |
| 'X-Requested-Quality': String(quality), | |
| 'X-Requested-Width': width, | |
| }, | |
| }) | |
| } catch (error) { | |
| console.error('Image optimization error:', error) | |
| return new Response(`Failed to optimize image: ${error}`, { status: 500 }) | |
| } | |
| }, | |
| }, | |
| }, | |
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Image as UnpicImage, type ImageProps as UnpicImageProps } from '@unpic/react' | |
| import { type FC, type SyntheticEvent, useCallback, useLayoutEffect, useRef, useState } from 'react' | |
| import { cn } from '@/lib/utils' | |
| // Size for blur placeholder (LQIP) | |
| const BLUR_SIZE = 8 | |
| const BLUR_QUALITY = 70 | |
| export type ImageProps = UnpicImageProps & { | |
| /** Enable blur placeholder with fade transition. Defaults to true. */ | |
| blur?: boolean | |
| } | |
| /** | |
| * Generates a Vercel optimized image URL. | |
| * Uses Vercel's /_vercel/image endpoint for on-demand image optimization. | |
| * | |
| * @param url - The source image URL | |
| * @param width - Desired width in pixels | |
| * @param quality - Quality (1-100), defaults to 75 | |
| */ | |
| export const getVercelImageUrl = (url: string, width: number, quality = 75): string => { | |
| const params = new URLSearchParams() | |
| params.set('url', url) | |
| params.set('w', String(width)) | |
| params.set('q', String(quality)) | |
| return `/_vercel/image?${params.toString()}` | |
| } | |
| /** | |
| * Generates a tiny blur placeholder URL for LQIP. | |
| */ | |
| const getBlurPlaceholderUrl = (src: string): string => | |
| getVercelImageUrl(src, BLUR_SIZE, BLUR_QUALITY) | |
| /** | |
| * Optimized Image component using Vercel Image Optimization. | |
| * | |
| * All images that don't match a known CDN are routed through the | |
| * `/_vercel/image` endpoint: | |
| * | |
| * - **Production (Vercel)**: Vercel's edge network handles optimization with | |
| * WebP/AVIF conversion, responsive resizing, and 1-year edge caching. | |
| * | |
| * - **Development**: A local Sharp-based optimizer validates requests against | |
| * the same vercel.ts config, ensuring dev/prod parity. Config errors | |
| * (wrong domains, invalid sizes) fail locally instead of only surfacing | |
| * in production. | |
| * | |
| * Supported image sources (configured in vercel.ts): | |
| * - PreachX S3/Tigris storage (s3.preachx.ai, t3.storage.dev) | |
| * - YouTube thumbnails (*.ytimg.com, *.ggpht.com) | |
| * | |
| * For images from known CDNs (Cloudinary, Imgix, Contentful, etc.), | |
| * Unpic automatically uses their native optimization. | |
| * | |
| * **Blur placeholder**: By default, a blurred low-quality image placeholder | |
| * is shown while the full image loads. The blur fades out smoothly when | |
| * the image loads. Set `blur={false}` to disable. | |
| * | |
| * Uses useLayoutEffect to check image complete state before browser paint, | |
| * preventing blur flash on cached images. The `data-loading` attribute | |
| * is only set to "true" if the image is actually still loading. | |
| * | |
| * When onLoad is provided, crossOrigin="anonymous" is automatically set | |
| * to enable canvas operations (like color extraction) on the loaded image. | |
| */ | |
| export const Image: FC<ImageProps> = (props) => { | |
| const { blur = true, className, onError, onLoad, src, ...rest } = props | |
| const imgRef = useRef<HTMLImageElement>(null) | |
| const [useFallback, setUseFallback] = useState(false) | |
| // useLayoutEffect runs synchronously after DOM mutations but BEFORE browser paint | |
| // This lets us check if image is cached and update data-loading before user sees anything | |
| useLayoutEffect(() => { | |
| const img = imgRef.current | |
| if (!img) { | |
| return | |
| } | |
| // If image is already complete (cached), ensure no loading state | |
| if (img.complete && img.naturalWidth > 0) { | |
| img.removeAttribute('data-loading') | |
| } else { | |
| // Image is actually loading, show blur | |
| img.dataset.loading = 'true' | |
| } | |
| const handleLoad = (): void => { | |
| img.removeAttribute('data-loading') | |
| } | |
| img.addEventListener('load', handleLoad) | |
| return () => { | |
| img.removeEventListener('load', handleLoad) | |
| } | |
| }, []) | |
| // Forward onLoad to user | |
| const handleLoad = useCallback( | |
| (event: SyntheticEvent<HTMLImageElement>) => { | |
| onLoad?.(event) | |
| }, | |
| [onLoad], | |
| ) | |
| // Handle error by falling back to original URL | |
| const handleError = useCallback( | |
| (event: SyntheticEvent<HTMLImageElement>) => { | |
| if (!useFallback && typeof src === 'string') { | |
| setUseFallback(true) | |
| } | |
| onError?.(event) | |
| }, | |
| [onError, src, useFallback], | |
| ) | |
| // Determine if we should show blur placeholder | |
| const showBlur = blur && typeof src === 'string' | |
| const blurUrl = showBlur ? getBlurPlaceholderUrl(src) : undefined | |
| // CSS classes for blur effect - only apply blur when data-loading="true" | |
| // By default (no attribute), image renders clear - no blur flash for cached images | |
| const blurClasses = showBlur ? 'data-[loading=true]:blur-lg' : '' | |
| return ( | |
| <UnpicImage | |
| background={blurUrl} | |
| className={cn('transition-all duration-64 ease-out', blurClasses, className)} | |
| crossOrigin={onLoad ? 'anonymous' : undefined} | |
| fallback={useFallback ? undefined : 'vercel'} | |
| onError={handleError} | |
| onLoad={handleLoad} | |
| ref={imgRef} | |
| src={src} | |
| {...rest} | |
| /> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { routes, type VercelConfig } from '@vercel/config/v1' | |
| export const config: VercelConfig = { | |
| // Enable Fluid Compute for better cold start performance | |
| // Fluid dynamically allocates resources and keeps functions warm longer | |
| fluid: true, | |
| headers: [ | |
| // Static asset caching - fonts (immutable, 1 year) | |
| routes.header('/fonts/(.*)', [ | |
| { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }, | |
| ]), | |
| // Static asset caching - build assets (immutable, 1 year) | |
| routes.header('/_build/(.*)', [ | |
| { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }, | |
| ]), | |
| // Static asset caching - images and icons (1 year) | |
| routes.header('/(.*\\.(?:ico|png|jpg|jpeg|gif|webp|avif|svg))', [ | |
| { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }, | |
| ]), | |
| // Security headers for all routes | |
| routes.header('/(.*)', [ | |
| // HSTS - Enforce HTTPS for 1 year, include subdomains | |
| { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, | |
| // Prevent MIME type sniffing | |
| { key: 'X-Content-Type-Options', value: 'nosniff' }, | |
| // Prevent clickjacking - SAMEORIGIN allows embedding on same domain | |
| { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, | |
| // Basic CSP - permissive initially, can be tightened later | |
| { | |
| key: 'Content-Security-Policy', | |
| value: [ | |
| "default-src 'self'", | |
| "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://us.i.posthog.com https://us-assets.i.posthog.com https://widget.productlane.com https://vercel.live https://js.stripe.com", | |
| "style-src 'self' 'unsafe-inline'", | |
| "img-src 'self' data: blob: https:", | |
| "font-src 'self' data:", | |
| "connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com https://*.stripe.com https://*.stripe.network wss://live.productlane.com wss: https:", | |
| "worker-src 'self' blob:", | |
| "frame-src 'self' https://vercel.live https://widget-app.productlane.com https://js.stripe.com https://*.stripe.com https://*.stripe.network", | |
| "frame-ancestors 'self'", | |
| "base-uri 'self'", | |
| "form-action 'self'", | |
| ].join('; '), | |
| }, | |
| // Referrer policy - send origin for same-origin, nothing for cross-origin | |
| { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, | |
| ]), | |
| ], | |
| images: { | |
| // Support modern image formats | |
| // @ts-expect-error - @vercel/config has broken types for formats (should be union array, not union | array) | |
| formats: ['image/avif', 'image/webp'], | |
| // Cache optimized images for 1 year (same as static assets) | |
| minimumCacheTTL: 31_536_000, | |
| // Remote patterns for allowed image sources (uses glob patterns, not regex) | |
| remotePatterns: [ | |
| { | |
| // PreachX S3/Tigris storage | |
| hostname: 's3.preachx.ai', | |
| protocol: 'https', | |
| }, | |
| { | |
| // Tigris storage direct domain | |
| hostname: 'preachx.t3.storage.dev', | |
| protocol: 'https', | |
| }, | |
| { | |
| // ISBN DB book cover images | |
| hostname: 'images.isbndb.com', | |
| protocol: 'https', | |
| }, | |
| { | |
| // YouTube thumbnails (i.ytimg.com, etc.) | |
| hostname: '*.ytimg.com', | |
| protocol: 'https', | |
| }, | |
| { | |
| // UploadThing storage | |
| hostname: 'pbljxsc2h9.ufs.sh', | |
| protocol: 'https', | |
| }, | |
| { | |
| // YouTube ggpht domain (yt3.ggpht.com, etc.) | |
| hostname: '*.ggpht.com', | |
| protocol: 'https', | |
| }, | |
| { | |
| // YouTube/Google user content (channel avatars, etc.) | |
| hostname: '*.googleusercontent.com', | |
| protocol: 'https', | |
| }, | |
| ], | |
| // Image sizes - must include all widths that unpic may request | |
| // 8 = blur placeholder (LQIP), rest are responsive breakpoints + unpic's default sizes | |
| sizes: [ | |
| 8, 16, 24, 32, 44, 48, 64, 85, 96, 128, 256, 384, 480, 640, 750, 828, 960, 1080, 1200, 1920, | |
| 2048, 3840, | |
| ], | |
| }, | |
| rewrites: [ | |
| routes.rewrite('/ingest/static/(.*)', 'https://us-assets.i.posthog.com/static/$1'), | |
| routes.rewrite('/ingest/(.*)', 'https://us.i.posthog.com/$1'), | |
| ], | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // add this to your vite.config | |
| server: { | |
| // Proxy /_vercel/image to our local /image route in development | |
| // In production, Vercel handles this at the edge | |
| proxy: { | |
| '/_vercel/image': { | |
| rewrite: (path) => path.replace(/^\/_vercel/, ''), | |
| // Skip SSL verification for mkcert's locally-trusted certificate | |
| secure: false, | |
| target: 'http://localhost:3000', | |
| }, | |
| }, | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment