UNPKG

@urami/core

Version:

Automatic image optimization server endpoints

547 lines (530 loc) 16.9 kB
// src/index.ts import path3 from "node:path"; import { URL } from "node:url"; import isAnimated from "is-animated"; // src/constants/mimeTypes.ts var AVIF = "image/avif"; var WEBP = "image/webp"; var PNG = "image/png"; var JPEG = "image/jpeg"; var GIF = "image/gif"; var SVG = "image/svg+xml"; // src/constants/animateableTypes.ts var animateableTypes = [WEBP, PNG, GIF]; // src/constants/defaultConfig.ts var defaultConfig = { avif: false, ttl: 1e3 * 60 * 60 * 24 * 7, storePath: ".urami/images" }; // src/constants/vectorTypes.ts var vectorTypes = [SVG]; // src/functions/detectContentType.ts var JPEG_SIG = [255, 216, 255]; var PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10]; var GIF_SIG = [71, 73, 70, 56]; var WEBP_SIG = [82, 73, 70, 70, 0, 0, 0, 0, 87, 69, 66, 80]; var SVG_SIG = [60, 63, 120, 109, 108]; var AVIF_SIG = [0, 0, 0, 0, 102, 116, 121, 112, 97, 118, 105, 102]; var typeCache = /* @__PURE__ */ new Map(); var detectContentType = (buffer) => { if (!buffer || buffer.length === 0) { return null; } const bytesToCheck = Math.min(16, buffer.length); const cacheKey = Array.from(buffer.slice(0, bytesToCheck)).toString(); if (typeCache.has(cacheKey)) { return typeCache.get(cacheKey) || null; } let result = null; if (buffer.length >= 3 && buffer[0] === JPEG_SIG[0] && buffer[1] === JPEG_SIG[1] && buffer[2] === JPEG_SIG[2]) { result = JPEG; } else if (buffer.length >= PNG_SIG.length && PNG_SIG.every((b, i) => buffer[i] === b)) { result = PNG; } else if (buffer.length >= GIF_SIG.length && GIF_SIG.every((b, i) => buffer[i] === b)) { result = GIF; } else if (buffer.length >= WEBP_SIG.length && WEBP_SIG.every((b, i) => b === 0 || buffer[i] === b)) { result = WEBP; } else if (buffer.length >= SVG_SIG.length && SVG_SIG.every((b, i) => buffer[i] === b)) { result = SVG; } else if (buffer.length >= AVIF_SIG.length && AVIF_SIG.every((b, i) => b === 0 || buffer[i] === b)) { result = AVIF; } typeCache.set(cacheKey, result); if (typeCache.size > 1e3) { const firstKey = typeCache.keys().next().value; if (firstKey) { typeCache.delete(firstKey); } } return result; }; // src/functions/error.ts var error = (status, message) => new Response(message, { status }); // src/functions/getHash.ts import { createHash } from "node:crypto"; var getHash = (items) => { const hash = createHash("sha256"); for (let item of items) { if (typeof item === "number") hash.update(String(item)); else { hash.update(item); } } return hash.digest("base64").replace(/\//g, "-"); }; // src/constants/cacheVersion.ts var cacheVersion = 1; // src/functions/getCacheKey.ts var getCacheKey = (href, width, quality, mimeType) => { return getHash([cacheVersion, href, width, quality, mimeType]); }; // src/functions/getExtension.ts import send from "send"; send.mime.define({ "image/avif": ["avif"] }); function getExtension(contentType) { const { mime } = send; if ("getExtension" in mime) { return mime.getExtension(contentType); } return mime.extension(contentType); } // src/functions/getMaxAge.ts var parseCacheControl = (str) => { const map = /* @__PURE__ */ new Map(); if (!str) { return map; } for (let directive of str.split(",")) { let [key, value] = directive.trim().split("="); key = key.toLowerCase(); if (value) { value = value.toLowerCase(); } map.set(key, value); } return map; }; var getMaxAge = (str) => { const map = parseCacheControl(str); if (map) { let age = map.get("s-maxage") || map.get("max-age") || ""; if (age.startsWith('"') && age.endsWith('"')) { age = age.slice(1, -1); } const n = parseInt(age, 10); if (!isNaN(n)) { return n; } } return 0; }; // src/functions/getSupportedMimeType.ts import { mediaType } from "@hapi/accept"; var getSupportedMimeType = (options, accept = "") => { const mimeType = mediaType(accept, options); return accept.includes(mimeType) ? mimeType : ""; }; // src/functions/optimizeImage.ts import sharp from "sharp"; var sharpInstance = null; var metadataCache = /* @__PURE__ */ new Map(); var getImageMetadata = async (buffer, bufferHash) => { const cachedMetadata = metadataCache.get(bufferHash); if (cachedMetadata) return cachedMetadata; if (!sharpInstance) { sharpInstance = sharp(buffer); } else { sharpInstance.removeAlpha().withMetadata(); sharpInstance = sharp(buffer); } const metadata = await sharpInstance.metadata(); metadataCache.set(bufferHash, metadata); if (metadataCache.size > 1e3) { const firstKey = metadataCache.keys().next().value; if (firstKey) { metadataCache.delete(firstKey); } } return metadata; }; var optimizeImage = async (buffer, contentType, quality, width, height, bufferHash) => { try { const transformer = sharp(buffer, { failOnError: false // More resilient to slightly corrupted images }); transformer.rotate(); if (!height && width > 0) { const metadata = await getImageMetadata(buffer, bufferHash || ""); if (metadata.width && metadata.width > width) { transformer.resize(width); } } else if (height && width) { transformer.resize(width, height); } switch (contentType) { case AVIF: if (transformer.avif) { const avifQuality = quality - 15; transformer.avif({ quality: Math.max(avifQuality, 0), chromaSubsampling: "4:2:0" // same as webp }); } else { transformer.webp({ quality }); } break; case WEBP: transformer.webp({ quality }); break; case PNG: transformer.png({ quality }); break; case JPEG: default: transformer.jpeg({ quality }); break; } return transformer.toBuffer(); } catch (err) { console.error("Image optimization failed:", err); return null; } }; // src/functions/readImageFileSystem.ts import fs from "node:fs"; import path from "node:path"; var cacheMap = /* @__PURE__ */ new Map(); var CLEANUP_INTERVAL = 10 * 60 * 1e3; setInterval(() => { const now = Date.now(); for (const [key, entry] of cacheMap.entries()) { if (entry.expireAt < now) { cacheMap.delete(key); } } }, CLEANUP_INTERVAL); var readImageFileSystem = async (cacheKey, cacheDirectory) => { const now = Date.now(); const cachedInfo = cacheMap.get(cacheKey); if (cachedInfo && cachedInfo.expireAt > now) { try { const buffer = await fs.promises.readFile(cachedInfo.filePath); return { buffer, etag: cachedInfo.etag, maxAge: cachedInfo.maxAge, contentType: cachedInfo.contentType || detectContentType(buffer) }; } catch { cacheMap.delete(cacheKey); } } try { const requestedDirectory = path.join(cacheDirectory, cacheKey); const files = await fs.promises.readdir(requestedDirectory); if (files.length > 1) { const fileStats = await Promise.all( files.map(async (file) => { const filePath = path.join(requestedDirectory, file); const stats = await fs.promises.stat(filePath); return { file, mtime: stats.mtime }; }) ); files.sort((a, b) => { const statsA = fileStats.find((stat) => stat.file === a); const statsB = fileStats.find((stat) => stat.file === b); return (statsA?.mtime?.getTime() || 0) - (statsB?.mtime?.getTime() || 0); }); } const deletionPromises = []; for (const file of files) { const [maxAgeString, expireAtString, etag, extension] = file.split("."); const filePath = path.join(requestedDirectory, file); const expireAt = Number(expireAtString); const maxAge = Number(maxAgeString); if (expireAt < now) { deletionPromises.push(fs.promises.rm(filePath).catch(() => { })); continue; } try { const buffer = await fs.promises.readFile(filePath); const contentType = detectContentType(buffer); cacheMap.set(cacheKey, { filePath, etag, maxAge, expireAt, contentType: contentType || void 0 }); if (deletionPromises.length > 0) { Promise.all(deletionPromises).catch(() => { }); } return { buffer, etag, maxAge, contentType }; } catch (err) { continue; } } if (deletionPromises.length > 0) { Promise.all(deletionPromises).catch(() => { }); } } catch (err) { } return null; }; // src/functions/sendResponse.ts var sendResponse = (payload, cacheHit, extraHeaders = {}) => new Response(payload.buffer, { headers: { Vary: "Accept", "Content-Type": payload.contentType ?? "", "Cache-Control": `public, max-age=${Math.floor(payload.maxAge / 1e3)}, must-revalidate`, "CDN-Cache-Control": `max-age=${Math.floor(payload.maxAge / 1e3)}`, "Content-Length": Buffer.byteLength(payload.buffer).toString(), "Content-Security-Policy": "script-src 'none'; frame-src 'none'; sandbox;", "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", ETag: payload.etag, "X-SvelteAIO-Cache": cacheHit, ...extraHeaders } }); // src/functions/writeImageToFileSystem.ts import fs2 from "node:fs"; import path2 from "node:path"; var extensionCache = /* @__PURE__ */ new Map(); var pendingDirectories = /* @__PURE__ */ new Set(); var writeImageToFileSystem = async (cacheKey, contentType, maxAge, etag, buffer, cacheDirectory) => { let extension = extensionCache.get(contentType); if (!extension) { extension = getExtension(contentType) || "bin"; extensionCache.set(contentType, extension); } const targetDirectory = path2.join(cacheDirectory, cacheKey); const expireAt = maxAge + Date.now(); const targetFileName = `${maxAge}.${expireAt}.${etag}.${extension}`; const targetFilePath = path2.join(targetDirectory, targetFileName); if (pendingDirectories.has(targetDirectory)) { await new Promise((resolve) => setTimeout(resolve, 10)); } pendingDirectories.add(targetDirectory); try { await fs2.promises.mkdir(targetDirectory, { recursive: true }); cleanExpiredFiles(targetDirectory).catch(() => { }); await fs2.promises.writeFile(targetFilePath, buffer); return; } catch (err) { try { await fs2.promises.access(targetFilePath); await fs2.promises.rm(targetFilePath); } catch { } console.error("Cache write error:", err); } finally { pendingDirectories.delete(targetDirectory); } }; async function cleanExpiredFiles(directory) { try { const now = Date.now(); const files = await fs2.promises.readdir(directory); const filesToProcess = files.slice(0, 10); const deletionPromises = filesToProcess.map(async (file) => { try { const expireAtString = file.split(".")[1]; const expireAt = Number(expireAtString); if (isNaN(expireAt) || expireAt < now) { await fs2.promises.rm(path2.join(directory, file)); } } catch { } }); await Promise.all(deletionPromises); } catch { } } // src/index.ts var createRequestHandler = (config = {}) => async (request) => { const mergedConfig = { ...defaultConfig, ...config }; const targetCacheDirectory = path3.join( process.cwd(), mergedConfig.storePath ); const ifNoneMatch = request.headers.get("If-None-Match"); try { 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") ?? ""); let targetUrl; let targetDomain = null; try { targetUrl = new URL(url); } catch { 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"); } } url = targetUrl.toString(); const hostname = targetUrl.hostname; const remoteDomainAllowed = !mergedConfig.remoteDomains || mergedConfig.remoteDomains.includes(hostname); if (!remoteDomainAllowed) { throw error(403, "not allowed to optimize"); } 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 { } if (!mergedConfig.allowedDomains.includes(refererHost)) { throw error(403, "not allowed to access"); } } const mimeType = getSupportedMimeType( ["image/webp", ...mergedConfig.avif ? ["image/avif"] : []], request.headers.get("accept") ?? "" ); const cacheKey = getCacheKey(url, width, quality, mimeType); const cacheResponse = await readImageFileSystem( cacheKey, targetCacheDirectory ); 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 / 1e3)}, must-revalidate`, ETag: cacheResponse.etag, "X-SvelteAIO-Cache": "HIT", "X-Urami-Cache": "HIT" } }); } return sendResponse(cacheResponse, "HIT"); } const fetchedImage = await fetch(url); if (!fetchedImage.ok) { throw error(fetchedImage.status, `upstream error: ${fetchedImage.statusText}`); } const upstreamBuffer = Buffer.from(await fetchedImage.arrayBuffer()); const upstreamHash = getHash([upstreamBuffer]); const upstreamType = detectContentType(upstreamBuffer) || (fetchedImage.headers.get("Content-Type") ?? ""); const maxAge = getMaxAge(fetchedImage.headers.get("Cache-Control")); const vector = vectorTypes.includes(upstreamType); const animated = animateableTypes.includes(upstreamType) && isAnimated(upstreamBuffer); if (vector || animated) { 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" } ); } let contentType; if (mimeType) { contentType = mimeType; } else if (upstreamType?.startsWith("image/") && getExtension(upstreamType) && upstreamType !== WEBP && upstreamType !== AVIF) { contentType = upstreamType; } else { contentType = JPEG; } const optimizedBuffer = await optimizeImage( upstreamBuffer, contentType, quality, width, void 0, // height upstreamHash // pass buffer hash for metadata caching ); if (optimizedBuffer === null) { throw error(500, "unable to optimize image"); } const optimizedEtag = getHash([optimizedBuffer]); const payload = { buffer: optimizedBuffer, contentType, maxAge: Math.max(maxAge, mergedConfig.ttl), etag: optimizedEtag }; if (ifNoneMatch === optimizedEtag) { return new Response(null, { status: 304, headers: { Vary: "Accept", "Cache-Control": `public, max-age=${Math.floor(payload.maxAge / 1e3)}, must-revalidate`, ETag: optimizedEtag, "X-SvelteAIO-Cache": "MISS", "X-Urami-Cache": "MISS" } }); } writeImageToFileSystem( cacheKey, contentType, payload.maxAge, payload.etag, payload.buffer, targetCacheDirectory ).catch((err) => { console.error("Cache write error:", err); }); return sendResponse(payload, "MISS"); } catch (err) { if (err instanceof Error && "status" in err && typeof err.status === "number") { throw err; } console.error("Image processing error:", err); throw error(500, "unable to optimize image"); } }; export { createRequestHandler }; //# sourceMappingURL=index.js.map