next
Version:
The React Framework
664 lines (663 loc) • 23.4 kB
JavaScript
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.imageOptimizer = imageOptimizer;
exports.sendResponse = sendResponse;
exports.getHash = getHash;
exports.detectContentType = detectContentType;
exports.getMaxAge = getMaxAge;
exports.resizeImage = resizeImage;
exports.getImageSize = getImageSize;
var _accept = require("next/dist/compiled/@hapi/accept");
var _crypto = require("crypto");
var _fs = require("fs");
var _getOrientation = require("next/dist/compiled/get-orientation");
var _imageSize = _interopRequireDefault(require("next/dist/compiled/image-size"));
var _isAnimated = _interopRequireDefault(require("next/dist/compiled/is-animated"));
var _contentDisposition = _interopRequireDefault(require("next/dist/compiled/content-disposition"));
var _path = require("path");
var _url = _interopRequireDefault(require("url"));
var _main = require("./lib/squoosh/main");
var _sendPayload = require("./send-payload");
var _serveStatic = require("./serve-static");
var _chalk = _interopRequireDefault(require("next/dist/compiled/chalk"));
var _mockRequest = require("./lib/mock-request");
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : {
default: obj
};
}
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 CACHE_VERSION = 3;
const ANIMATABLE_TYPES = [
WEBP,
PNG,
GIF
];
const VECTOR_TYPES = [
SVG
];
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
;
let sharp;
try {
sharp = require(process.env.NEXT_SHARP_PATH || 'sharp');
} catch (e) {
// Sharp not present on the server, Squoosh fallback will be used
}
let showSharpMissingWarning = process.env.NODE_ENV === 'production';
class ImageOptimizerCache {
static validateParams(req, query, nextConfig, isDev) {
const imageData = nextConfig.images;
const { deviceSizes =[] , imageSizes =[] , domains =[] , minimumCacheTTL =60 , formats =[
'image/webp'
] , } = imageData;
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 (!domains || !domains.includes(hrefParsed.hostname)) {
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 || 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);
}
if (!sizes.includes(width)) {
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 = (0, _path).join(distDir, 'cache', 'images');
this.nextConfig = nextConfig;
}
async get(cacheKey) {
try {
const cacheDir = (0, _path).join(this.cacheDir, cacheKey);
const files = await _fs.promises.readdir(cacheDir);
const now = Date.now();
for (const file of files){
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
const buffer = await _fs.promises.readFile((0, _path).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 || value === void 0 ? 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((0, _path).join(this.cacheDir, cacheKey), value.extension, revalidate, expireAt, value.buffer, value.etag);
} catch (err) {
console.error(`Failed to write image to cache ${cacheKey}`, err);
}
}
}
exports.ImageOptimizerCache = ImageOptimizerCache;
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;
}
}
}
exports.ImageError = ImageError;
async function imageOptimizer(_req, _res, paramsResult, nextConfig, 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 { resBuffers , req: mockReq , res: mockRes , streamPromise: isStreamFinished , } = (0, _mockRequest).mockRequest(href, _req.headers, _req.method || 'GET', _req.connection);
await handleRequest(mockReq, mockRes, _url.default.parse(href, true));
await isStreamFinished;
if (!mockRes.statusCode) {
console.error('image response failed for', href, mockRes.statusCode);
throw new ImageError(mockRes.statusCode, '"url" parameter is valid but internal response is invalid');
}
upstreamBuffer = Buffer.concat(resBuffers);
upstreamType = detectContentType(upstreamBuffer) || mockRes.getHeader('Content-Type');
maxAge = getMaxAge(mockRes.getHeader('Cache-Control'));
} 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 === 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');
}
if (upstreamType) {
const vector = VECTOR_TYPES.includes(upstreamType);
const animate = ANIMATABLE_TYPES.includes(upstreamType) && (0, _isAnimated).default(upstreamBuffer);
if (vector || animate) {
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge
};
}
if (!upstreamType.startsWith('image/')) {
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 || upstreamType === void 0 ? void 0 : upstreamType.startsWith('image/')) && (0, _serveStatic).getExtension(upstreamType) && upstreamType !== WEBP && upstreamType !== AVIF) {
contentType = upstreamType;
} else {
contentType = JPEG;
}
try {
let optimizedBuffer;
if (sharp) {
// Begin sharp transformation logic
const transformer = sharp(upstreamBuffer);
transformer.rotate();
const { width: metaWidth } = await transformer.metadata();
if (metaWidth && metaWidth > width) {
transformer.resize(width);
}
if (contentType === AVIF) {
if (transformer.avif) {
const avifQuality = quality - 15;
transformer.avif({
quality: Math.max(avifQuality, 0),
chromaSubsampling: '4:2:0'
});
} else {
console.warn(_chalk.default.yellow.bold('Warning: ') + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add 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 {
var ref;
if (showSharpMissingWarning && ((ref = nextConfig.experimental) === null || ref === void 0 ? void 0 : ref.outputStandalone)) {
// TODO: should we ensure squoosh also works even though we don't
// recommend it be used in production and this is a production feature
console.error(`Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly`);
throw new ImageError(500, 'internal server error');
}
// Show sharp warning in production once
if (showSharpMissingWarning) {
console.warn(_chalk.default.yellow.bold('Warning: ') + `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add 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 (0, _getOrientation).getOrientation(upstreamBuffer);
const operations = [];
if (orientation === _getOrientation.Orientation.RIGHT_TOP) {
operations.push({
type: 'rotate',
numRotations: 1
});
} else if (orientation === _getOrientation.Orientation.BOTTOM_RIGHT) {
operations.push({
type: 'rotate',
numRotations: 2
});
} else if (orientation === _getOrientation.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
}
operations.push({
type: 'resize',
width
});
if (contentType === AVIF) {
optimizedBuffer = await (0, _main).processBuffer(upstreamBuffer, operations, 'avif', quality);
} else if (contentType === WEBP) {
optimizedBuffer = await (0, _main).processBuffer(upstreamBuffer, operations, 'webp', quality);
} else if (contentType === PNG) {
optimizedBuffer = await (0, _main).processBuffer(upstreamBuffer, operations, 'png', quality);
} else if (contentType === JPEG) {
optimizedBuffer = await (0, _main).processBuffer(upstreamBuffer, operations, 'jpeg', quality);
}
// End Squoosh transformation logic
}
if (optimizedBuffer) {
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(500, 'Unable to optimize image and unable to fallback to upstream image');
}
}
}
async function writeToCacheDir(dir, extension, maxAge, expireAt, buffer, etag) {
const filename = (0, _path).join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
// Added in: v14.14.0 https://nodejs.org/api/fs.html#fspromisesrmpath-options
// attempt cleaning up existing stale cache
if (_fs.promises.rm) {
await _fs.promises.rm(dir, {
force: true,
recursive: true
}).catch(()=>{});
} else {
await _fs.promises.rmdir(dir, {
recursive: true
}).catch(()=>{});
}
await _fs.promises.mkdir(dir, {
recursive: true
});
await _fs.promises.writeFile(filename, buffer);
}
function getFileNameWithExtension(url, contentType) {
const [urlWithoutQueryParams] = url.split('?');
const fileNameWithExtension = urlWithoutQueryParams.split('/').pop();
if (!contentType || !fileNameWithExtension) {
return;
}
const [fileName] = fileNameWithExtension.split('.');
const extension = (0, _serveStatic).getExtension(contentType);
return `${fileName}.${extension}`;
}
function setResponseHeaders(req, res, url, etag, contentType, isStatic, xCache, contentSecurityPolicy) {
res.setHeader('Vary', 'Accept');
res.setHeader('Cache-Control', isStatic ? 'public, max-age=315360000, immutable' : `public, max-age=0, must-revalidate`);
if ((0, _sendPayload).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);
if (fileName) {
res.setHeader('Content-Disposition', (0, _contentDisposition).default(fileName, {
type: 'inline'
}));
}
if (contentSecurityPolicy) {
res.setHeader('Content-Security-Policy', contentSecurityPolicy);
}
res.setHeader('X-Nextjs-Cache', xCache);
return {
finished: false
};
}
function sendResponse(req, res, url, extension, buffer, isStatic, xCache, contentSecurityPolicy) {
const contentType = (0, _serveStatic).getContentType(extension);
const etag = getHash([
buffer
]);
const result = setResponseHeaders(req, res, url, etag, contentType, isStatic, xCache, contentSecurityPolicy);
if (!result.finished) {
res.end(buffer);
}
}
function getSupportedMimeType(options, accept = '') {
const mimeType = (0, _accept).mediaType(accept, options);
return accept.includes(mimeType) ? mimeType : '';
}
function getHash(items) {
const hash = (0, _crypto).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, '-');
}
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;
}
function detectContentType(buffer) {
if ([
255,
216,
255
].every((b, i)=>buffer[i] === b
)) {
return JPEG;
}
if ([
137,
80,
78,
71,
13,
10,
26,
10
].every((b, i)=>buffer[i] === b
)) {
return PNG;
}
if ([
71,
73,
70,
56
].every((b, i)=>buffer[i] === b
)) {
return GIF;
}
if ([
82,
73,
70,
70,
0,
0,
0,
0,
87,
69,
66,
80
].every((b, i)=>!b || buffer[i] === b
)) {
return WEBP;
}
if ([
60,
63,
120,
109,
108
].every((b, i)=>buffer[i] === b
)) {
return SVG;
}
if ([
0,
0,
0,
0,
102,
116,
121,
112,
97,
118,
105,
102
].every((b, i)=>!b || buffer[i] === b
)) {
return AVIF;
}
return null;
}
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;
}
async function resizeImage(content, dimension, size, // Should match VALID_BLUR_EXT
extension, quality) {
if (sharp) {
const transformer = sharp(content);
if (extension === 'avif') {
if (transformer.avif) {
transformer.avif({
quality
});
} else {
console.warn(_chalk.default.yellow.bold('Warning: ') + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + 'Read more: https://nextjs.org/docs/messages/sharp-version-avif');
transformer.webp({
quality
});
}
} else if (extension === 'webp') {
transformer.webp({
quality
});
} else if (extension === 'png') {
transformer.png({
quality
});
} else if (extension === 'jpeg') {
transformer.jpeg({
quality
});
}
if (dimension === 'width') {
transformer.resize(size);
} else {
transformer.resize(null, size);
}
const buf = await transformer.toBuffer();
return buf;
} else {
const resizeOperationOpts = dimension === 'width' ? {
type: 'resize',
width: size
} : {
type: 'resize',
height: size
};
const buf = await (0, _main).processBuffer(content, [
resizeOperationOpts
], extension, quality);
return buf;
}
}
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 { width , height } = await (0, _main).decodeBuffer(buffer);
return {
width,
height
};
}
}
const { width , height } = (0, _imageSize).default(buffer);
return {
width,
height
};
}
class Deferred {
constructor(){
this.promise = new Promise((resolve, reject)=>{
this.resolve = resolve;
this.reject = reject;
});
}
}
exports.Deferred = Deferred;
//# sourceMappingURL=image-optimizer.js.map
;