import { createHash } from "crypto"; import { promises } from "fs"; import { cpus } from "os"; import { mediaType } from "next/dist/compiled/@hapi/accept"; import { bold, yellow } from "../lib/picocolors"; import contentDisposition from "next/dist/compiled/content-disposition"; import { getOrientation, Orientation } from "next/dist/compiled/get-orientation"; import imageSizeOf from "next/dist/compiled/image-size"; import isAnimated from "next/dist/compiled/is-animated"; import { join } from "path"; import nodeUrl from "url"; import { getImageBlurSvg } from "../shared/lib/image-blur-svg"; import { hasMatch } from "../shared/lib/match-remote-pattern"; import { createRequestResponseMocks } from "./lib/mock-request"; import { sendEtagResponse } from "./send-payload"; import { getContentType, getExtension } from "./serve-static"; const AVIF = "image/avif"; const WEBP = "image/webp"; const PNG = "image/png"; const JPEG = "image/jpeg"; const GIF = "image/gif"; const SVG = "image/svg+xml"; const ICO = "image/x-icon"; const CACHE_VERSION = 3; const ANIMATABLE_TYPES = [ WEBP, PNG, GIF ]; const VECTOR_TYPES = [ SVG ]; const BLUR_IMG_SIZE = 8 // should match `next-image-loader` ; const BLUR_QUALITY = 70 // should match `next-image-loader` ; let sharp; try { sharp = require(process.env.NEXT_SHARP_PATH || "sharp"); if (sharp && sharp.concurrency() > 1) { // Reducing concurrency should reduce the memory usage too. // We more aggressively reduce in dev but also reduce in prod. // https://sharp.pixelplumbing.com/api-utility#concurrency const divisor = process.env.NODE_ENV === "development" ? 4 : 2; sharp.concurrency(Math.floor(Math.max(cpus().length / divisor, 1))); } } catch (e) { // Sharp not present on the server, Squoosh fallback will be used } let showSharpMissingWarning = process.env.NODE_ENV === "production"; function getSupportedMimeType(options, accept = "") { const mimeType = mediaType(accept, options); return accept.includes(mimeType) ? mimeType : ""; } export function getHash(items) { const hash = createHash("sha256"); for (let item of items){ if (typeof item === "number") hash.update(String(item)); else { hash.update(item); } } // See https://en.wikipedia.org/wiki/Base64#Filenames return hash.digest("base64").replace(/\//g, "-"); } async function writeToCacheDir(dir, extension, maxAge, expireAt, buffer, etag) { const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`); await promises.rm(dir, { recursive: true, force: true }).catch(()=>{}); await promises.mkdir(dir, { recursive: true }); await promises.writeFile(filename, buffer); } /** * Inspects the first few bytes of a buffer to determine if * it matches the "magic number" of known file signatures. * https://en.wikipedia.org/wiki/List_of_file_signatures */ export function detectContentType(buffer) { if ([ 0xff, 0xd8, 0xff ].every((b, i)=>buffer[i] === b)) { return JPEG; } if ([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ].every((b, i)=>buffer[i] === b)) { return PNG; } if ([ 0x47, 0x49, 0x46, 0x38 ].every((b, i)=>buffer[i] === b)) { return GIF; } if ([ 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50 ].every((b, i)=>!b || buffer[i] === b)) { return WEBP; } if ([ 0x3c, 0x3f, 0x78, 0x6d, 0x6c ].every((b, i)=>buffer[i] === b)) { return SVG; } if ([ 0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66 ].every((b, i)=>!b || buffer[i] === b)) { return AVIF; } if ([ 0x00, 0x00, 0x01, 0x00 ].every((b, i)=>buffer[i] === b)) { return ICO; } return null; } export class ImageOptimizerCache { static validateParams(req, query, nextConfig, isDev) { var _nextConfig_images; const imageData = nextConfig.images; const { deviceSizes = [], imageSizes = [], domains = [], minimumCacheTTL = 60, formats = [ "image/webp" ] } = imageData; const remotePatterns = ((_nextConfig_images = nextConfig.images) == null ? void 0 : _nextConfig_images.remotePatterns) || []; const { url, w, q } = query; let href; if (!url) { return { errorMessage: '"url" parameter is required' }; } else if (Array.isArray(url)) { return { errorMessage: '"url" parameter cannot be an array' }; } let isAbsolute; if (url.startsWith("/")) { href = url; isAbsolute = false; } else { let hrefParsed; try { hrefParsed = new URL(url); href = hrefParsed.toString(); isAbsolute = true; } catch (_error) { return { errorMessage: '"url" parameter is invalid' }; } if (![ "http:", "https:" ].includes(hrefParsed.protocol)) { return { errorMessage: '"url" parameter is invalid' }; } if (!hasMatch(domains, remotePatterns, hrefParsed)) { return { errorMessage: '"url" parameter is not allowed' }; } } if (!w) { return { errorMessage: '"w" parameter (width) is required' }; } else if (Array.isArray(w)) { return { errorMessage: '"w" parameter (width) cannot be an array' }; } if (!q) { return { errorMessage: '"q" parameter (quality) is required' }; } else if (Array.isArray(q)) { return { errorMessage: '"q" parameter (quality) cannot be an array' }; } const width = parseInt(w, 10); if (width <= 0 || isNaN(width)) { return { errorMessage: '"w" parameter (width) must be a number greater than 0' }; } const sizes = [ ...deviceSizes || [], ...imageSizes || [] ]; if (isDev) { sizes.push(BLUR_IMG_SIZE); } const isValidSize = sizes.includes(width) || isDev && width <= BLUR_IMG_SIZE; if (!isValidSize) { return { errorMessage: `"w" parameter (width) of ${width} is not allowed` }; } const quality = parseInt(q); if (isNaN(quality) || quality < 1 || quality > 100) { return { errorMessage: '"q" parameter (quality) must be a number between 1 and 100' }; } const mimeType = getSupportedMimeType(formats || [], req.headers["accept"]); const isStatic = url.startsWith(`${nextConfig.basePath || ""}/_next/static/media`); return { href, sizes, isAbsolute, isStatic, width, quality, mimeType, minimumCacheTTL }; } static getCacheKey({ href, width, quality, mimeType }) { return getHash([ CACHE_VERSION, href, width, quality, mimeType ]); } constructor({ distDir, nextConfig }){ this.cacheDir = join(distDir, "cache", "images"); this.nextConfig = nextConfig; } async get(cacheKey) { try { const cacheDir = join(this.cacheDir, cacheKey); const files = await promises.readdir(cacheDir); const now = Date.now(); for (const file of files){ const [maxAgeSt, expireAtSt, etag, extension] = file.split("."); const buffer = await promises.readFile(join(cacheDir, file)); const expireAt = Number(expireAtSt); const maxAge = Number(maxAgeSt); return { value: { kind: "IMAGE", etag, buffer, extension }, revalidateAfter: Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now(), curRevalidate: maxAge, isStale: now > expireAt }; } } catch (_) { // failed to read from cache dir, treat as cache miss } return null; } async set(cacheKey, value, { revalidate }) { if ((value == null ? void 0 : value.kind) !== "IMAGE") { throw new Error("invariant attempted to set non-image to image-cache"); } if (typeof revalidate !== "number") { throw new Error("invariant revalidate must be a number for image-cache"); } const expireAt = Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now(); try { await writeToCacheDir(join(this.cacheDir, cacheKey), value.extension, revalidate, expireAt, value.buffer, value.etag); } catch (err) { console.error(`Failed to write image to cache ${cacheKey}`, err); } } } export class ImageError extends Error { constructor(statusCode, message){ super(message); // ensure an error status is used > 400 if (statusCode >= 400) { this.statusCode = statusCode; } else { this.statusCode = 500; } } } function parseCacheControl(str) { const map = 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; } export function 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; } export async function optimizeImage({ buffer, contentType, quality, width, height, nextConfigOutput }) { let optimizedBuffer = buffer; if (sharp) { // Begin sharp transformation logic const transformer = sharp(buffer, { sequentialRead: true }); transformer.rotate(); if (height) { transformer.resize(width, height); } else { transformer.resize(width, undefined, { withoutEnlargement: true }); } if (contentType === AVIF) { if (transformer.avif) { const avifQuality = quality - 15; transformer.avif({ quality: Math.max(avifQuality, 0), chromaSubsampling: "4:2:0" }); } else { console.warn(yellow(bold("Warning: ")) + `Your installed version of the 'sharp' package does not support AVIF images. Run 'npm i sharp@latest' to upgrade to the latest version.\n` + "Read more: https://nextjs.org/docs/messages/sharp-version-avif"); transformer.webp({ quality }); } } else if (contentType === WEBP) { transformer.webp({ quality }); } else if (contentType === PNG) { transformer.png({ quality }); } else if (contentType === JPEG) { transformer.jpeg({ quality }); } optimizedBuffer = await transformer.toBuffer(); // End sharp transformation logic } else { if (showSharpMissingWarning && nextConfigOutput === "standalone") { console.error(`Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production`); throw new ImageError(500, "Internal Server Error"); } // Show sharp warning in production once if (showSharpMissingWarning) { console.warn(yellow(bold("Warning: ")) + `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'npm i sharp', and Next.js will use it automatically for Image Optimization.\n` + "Read more: https://nextjs.org/docs/messages/sharp-missing-in-production"); showSharpMissingWarning = false; } // Begin Squoosh transformation logic const orientation = await getOrientation(buffer); const operations = []; if (orientation === Orientation.RIGHT_TOP) { operations.push({ type: "rotate", numRotations: 1 }); } else if (orientation === Orientation.BOTTOM_RIGHT) { operations.push({ type: "rotate", numRotations: 2 }); } else if (orientation === Orientation.LEFT_BOTTOM) { operations.push({ type: "rotate", numRotations: 3 }); } else { // TODO: support more orientations // eslint-disable-next-line @typescript-eslint/no-unused-vars // const _: never = orientation } if (height) { operations.push({ type: "resize", width, height }); } else { operations.push({ type: "resize", width }); } const { processBuffer } = require("./lib/squoosh/main"); if (contentType === AVIF) { optimizedBuffer = await processBuffer(buffer, operations, "avif", quality); } else if (contentType === WEBP) { optimizedBuffer = await processBuffer(buffer, operations, "webp", quality); } else if (contentType === PNG) { optimizedBuffer = await processBuffer(buffer, operations, "png", quality); } else if (contentType === JPEG) { optimizedBuffer = await processBuffer(buffer, operations, "jpeg", quality); } } return optimizedBuffer; } export async function imageOptimizer(_req, _res, paramsResult, nextConfig, isDev, handleRequest) { let upstreamBuffer; let upstreamType; let maxAge; const { isAbsolute, href, width, mimeType, quality } = paramsResult; if (isAbsolute) { const upstreamRes = await fetch(href); if (!upstreamRes.ok) { console.error("upstream image response failed for", href, upstreamRes.status); throw new ImageError(upstreamRes.status, '"url" parameter is valid but upstream response is invalid'); } upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer()); upstreamType = detectContentType(upstreamBuffer) || upstreamRes.headers.get("Content-Type"); maxAge = getMaxAge(upstreamRes.headers.get("Cache-Control")); } else { try { const mocked = createRequestResponseMocks({ url: href, method: _req.method || "GET", headers: _req.headers, socket: _req.socket }); await handleRequest(mocked.req, mocked.res, nodeUrl.parse(href, true)); await mocked.res.hasStreamed; if (!mocked.res.statusCode) { console.error("image response failed for", href, mocked.res.statusCode); throw new ImageError(mocked.res.statusCode, '"url" parameter is valid but internal response is invalid'); } upstreamBuffer = Buffer.concat(mocked.res.buffers); upstreamType = detectContentType(upstreamBuffer) || mocked.res.getHeader("Content-Type"); const cacheControl = mocked.res.getHeader("Cache-Control"); maxAge = cacheControl ? getMaxAge(cacheControl) : 0; } catch (err) { console.error("upstream image response failed for", href, err); throw new ImageError(500, '"url" parameter is valid but upstream response is invalid'); } } if (upstreamType) { upstreamType = upstreamType.toLowerCase().trim(); if (upstreamType.startsWith("image/svg") && !nextConfig.images.dangerouslyAllowSVG) { console.error(`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`); throw new ImageError(400, '"url" parameter is valid but image type is not allowed'); } const vector = VECTOR_TYPES.includes(upstreamType); const animate = ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer); if (vector || animate) { return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }; } if (!upstreamType.startsWith("image/") || upstreamType.includes(",")) { console.error("The requested resource isn't a valid image for", href, "received", upstreamType); throw new ImageError(400, "The requested resource isn't a valid image."); } } let contentType; if (mimeType) { contentType = mimeType; } else if ((upstreamType == null ? void 0 : upstreamType.startsWith("image/")) && getExtension(upstreamType) && upstreamType !== WEBP && upstreamType !== AVIF) { contentType = upstreamType; } else { contentType = JPEG; } try { let optimizedBuffer = await optimizeImage({ buffer: upstreamBuffer, contentType, quality, width, nextConfigOutput: nextConfig.output }); if (optimizedBuffer) { if (isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) { const { getMetadata } = require("./lib/squoosh/main"); // During `next dev`, we don't want to generate blur placeholders with webpack // because it can delay starting the dev server. Instead, `next-image-loader.js` // will inline a special url to lazily generate the blur placeholder at request time. const meta = await getMetadata(optimizedBuffer); const opts = { blurWidth: meta.width, blurHeight: meta.height, blurDataURL: `data:${contentType};base64,${optimizedBuffer.toString("base64")}` }; optimizedBuffer = Buffer.from(unescape(getImageBlurSvg(opts))); contentType = "image/svg+xml"; } return { buffer: optimizedBuffer, contentType, maxAge: Math.max(maxAge, nextConfig.images.minimumCacheTTL) }; } else { throw new ImageError(500, "Unable to optimize buffer"); } } catch (error) { if (upstreamBuffer && upstreamType) { // If we fail to optimize, fallback to the original image return { buffer: upstreamBuffer, contentType: upstreamType, maxAge: nextConfig.images.minimumCacheTTL }; } else { throw new ImageError(400, "Unable to optimize image and unable to fallback to upstream image"); } } } function getFileNameWithExtension(url, contentType) { const [urlWithoutQueryParams] = url.split("?"); const fileNameWithExtension = urlWithoutQueryParams.split("/").pop(); if (!contentType || !fileNameWithExtension) { return "image.bin"; } const [fileName] = fileNameWithExtension.split("."); const extension = getExtension(contentType); return `${fileName}.${extension}`; } function setResponseHeaders(req, res, url, etag, contentType, isStatic, xCache, imagesConfig, maxAge, isDev) { res.setHeader("Vary", "Accept"); res.setHeader("Cache-Control", isStatic ? "public, max-age=315360000, immutable" : `public, max-age=${isDev ? 0 : maxAge}, must-revalidate`); if (sendEtagResponse(req, res, etag)) { // already called res.end() so we're finished return { finished: true }; } if (contentType) { res.setHeader("Content-Type", contentType); } const fileName = getFileNameWithExtension(url, contentType); res.setHeader("Content-Disposition", contentDisposition(fileName, { type: imagesConfig.contentDispositionType })); res.setHeader("Content-Security-Policy", imagesConfig.contentSecurityPolicy); res.setHeader("X-Nextjs-Cache", xCache); return { finished: false }; } export function sendResponse(req, res, url, extension, buffer, isStatic, xCache, imagesConfig, maxAge, isDev) { const contentType = getContentType(extension); const etag = getHash([ buffer ]); const result = setResponseHeaders(req, res, url, etag, contentType, isStatic, xCache, imagesConfig, maxAge, isDev); if (!result.finished) { res.setHeader("Content-Length", Buffer.byteLength(buffer)); res.end(buffer); } } export async function getImageSize(buffer, // Should match VALID_BLUR_EXT extension) { // TODO: upgrade "image-size" package to support AVIF // See https://github.com/image-size/image-size/issues/348 if (extension === "avif") { if (sharp) { const transformer = sharp(buffer); const { width, height } = await transformer.metadata(); return { width, height }; } else { const { decodeBuffer } = require("./lib/squoosh/main"); const { width, height } = await decodeBuffer(buffer); return { width, height }; } } const { width, height } = imageSizeOf(buffer); return { width, height }; } //# sourceMappingURL=image-optimizer.js.map