UNPKG

@urami/core

Version:

Automatic image optimization server endpoints

261 lines (225 loc) 8.75 kB
import path from "node:path"; import { URL } from "node:url"; import isAnimated from "is-animated"; import { AVIF, JPEG, WEBP } from "./constants/mimeTypes.js"; import { animateableTypes } from "./constants/animateableTypes.js"; import { defaultConfig } from "./constants/defaultConfig.js"; import { vectorTypes } from "./constants/vectorTypes.js"; import { detectContentType } from "./functions/detectContentType.js"; import { error } from "./functions/error.js"; import { getCacheKey } from "./functions/getCacheKey.js"; import { getExtension } from "./functions/getExtension.js"; import { getHash } from "./functions/getHash.js"; import { getMaxAge } from "./functions/getMaxAge.js"; import { getSupportedMimeType } from "./functions/getSupportedMimeType.js"; import { optimizeImage } from "./functions/optimizeImage.js"; import { readImageFileSystem } from "./functions/readImageFileSystem.js"; import { sendResponse } from "./functions/sendResponse.js"; import { writeImageToFileSystem } from "./functions/writeImageToFileSystem.js"; import type { Config } from "./@types/Config.js"; import type { ResponsePayload } from "./@types/ResponsePayload.js"; import type { RequestHandler } from "./@types/RequestHandler.js"; export const createRequestHandler = (config: Partial<Config> = {}): RequestHandler => async (request) => { // build general config const mergedConfig = { ...defaultConfig, ...config, }; const targetCacheDirectory = path.join( process.cwd(), mergedConfig.storePath, ); // Get If-None-Match header early for consistent use const ifNoneMatch = request.headers.get("If-None-Match"); try { // parse variables const fullRequestUrl = new URL(request.url); let url = fullRequestUrl.searchParams.get("url") ?? ""; const width = Number(fullRequestUrl.searchParams.get("w") ?? ""); const quality = Number(fullRequestUrl.searchParams.get("q") ?? ""); // Get URL target domain from referer or config let targetUrl: URL; let targetDomain: string | null = null; try { // Try to parse as an absolute URL first targetUrl = new URL(url); } catch { // If not absolute, construct using defaultDomain or referer targetDomain = mergedConfig.defaultDomain ?? request.headers.get("referer"); if (!targetDomain) throw error(400, "missing url"); try { targetUrl = new URL(url, targetDomain); } catch { throw error(400, "invalid url"); } } // Normalize URL for consistent caching url = targetUrl.toString(); const hostname = targetUrl.hostname; // Check domain permissions in one step const remoteDomainAllowed = !mergedConfig.remoteDomains || mergedConfig.remoteDomains.includes(hostname); if (!remoteDomainAllowed) { throw error(403, "not allowed to optimize"); } // Check referer permissions if in production if (process.env.NODE_ENV === "production" && mergedConfig.allowedDomains) { const referer = request.headers.get("referer"); let refererHost = "localhost"; try { if (referer) { refererHost = new URL(referer).hostname; } } catch { // Use default localhost if referer parsing fails } if (!mergedConfig.allowedDomains.includes(refererHost)) { throw error(403, "not allowed to access"); } } // Get supported mime type for response const mimeType = getSupportedMimeType( ["image/webp", ...(mergedConfig.avif ? ["image/avif"] : [])], request.headers.get("accept") ?? "", ); // Generate cache key once const cacheKey = getCacheKey(url, width, quality, mimeType); // Try to get cached response first const cacheResponse = await readImageFileSystem( cacheKey, targetCacheDirectory, ); // If cache hit, check ETag and return appropriate response if (cacheResponse !== null) { if (ifNoneMatch === cacheResponse.etag) { return new Response(null, { status: 304, headers: { Vary: "Accept", "Cache-Control": `public, max-age=${Math.floor(cacheResponse.maxAge / 1000)}, must-revalidate`, ETag: cacheResponse.etag, "X-SvelteAIO-Cache": "HIT", "X-Urami-Cache": "HIT", }, }); } return sendResponse(cacheResponse, "HIT"); } // Need to fetch and process the image const fetchedImage = await fetch(url); // Handle fetch errors if (!fetchedImage.ok) { throw error(fetchedImage.status, `upstream error: ${fetchedImage.statusText}`); } // Get image buffer const upstreamBuffer = Buffer.from(await fetchedImage.arrayBuffer()); // Compute hash once for various uses const upstreamHash = getHash([upstreamBuffer]); // Get content type and cache settings const upstreamType = detectContentType(upstreamBuffer) || (fetchedImage.headers.get("Content-Type") ?? ""); const maxAge = getMaxAge(fetchedImage.headers.get("Cache-Control")); // Handle vector and animated images differently const vector = vectorTypes.includes(upstreamType); const animated = animateableTypes.includes(upstreamType) && isAnimated(upstreamBuffer); if (vector || animated) { // ETag check for unprocessed images if (ifNoneMatch === upstreamHash) { return new Response(null, { status: 304, headers: { Vary: "Accept", "Cache-Control": "public, max-age=0, must-revalidate", ETag: upstreamHash, "X-SvelteAIO-Cache": "MISS", "X-Urami-Cache": "MISS", "X-Urami-Optimization": "animate-ignore", }, }); } return sendResponse( { buffer: upstreamBuffer, contentType: upstreamType, maxAge: 0, etag: upstreamHash, }, "MISS", { "X-Urami-Optimization": "animate-ignore", }, ); } // Determine target content type once let contentType: string; if (mimeType) { contentType = mimeType; } else if ( upstreamType?.startsWith("image/") && getExtension(upstreamType) && upstreamType !== WEBP && upstreamType !== AVIF ) { contentType = upstreamType; } else { contentType = JPEG; } // Optimize the image with the hash for metadata cache const optimizedBuffer = await optimizeImage( upstreamBuffer, contentType, quality, width, undefined, // height upstreamHash, // pass buffer hash for metadata caching ); if (optimizedBuffer === null) { throw error(500, "unable to optimize image"); } // Calculate ETag once const optimizedEtag = getHash([optimizedBuffer]); // Create response payload const payload: ResponsePayload = { buffer: optimizedBuffer, contentType, maxAge: Math.max(maxAge, mergedConfig.ttl), etag: optimizedEtag, }; // Check ETag match for optimized image if (ifNoneMatch === optimizedEtag) { return new Response(null, { status: 304, headers: { Vary: "Accept", "Cache-Control": `public, max-age=${Math.floor(payload.maxAge / 1000)}, must-revalidate`, ETag: optimizedEtag, "X-SvelteAIO-Cache": "MISS", "X-Urami-Cache": "MISS", }, }); } // Store in filesystem cache (don't await) writeImageToFileSystem( cacheKey, contentType, payload.maxAge, payload.etag, payload.buffer, targetCacheDirectory, ).catch(err => { // Log cache write failures but don't fail the request console.error("Cache write error:", err); }); // Return optimized image return sendResponse(payload, "MISS"); } catch (err) { // Better error handling with detailed messages when possible if (err instanceof Error && 'status' in err && typeof err.status === 'number') { throw err; // Rethrow our own error objects } console.error("Image processing error:", err); throw error(500, "unable to optimize image"); } };