UNPKG

next

Version:

The React Framework

801 lines (800 loc) • 33.8 kB
import { promises } from 'fs'; import { Agent as HttpAgent, request as httpRequest } from 'http'; import { Agent as HttpsAgent, request as httpsRequest } from 'https'; import { mediaType } from 'next/dist/compiled/@hapi/accept'; import contentDisposition from 'next/dist/compiled/content-disposition'; import { join } from 'path'; import { getImageBlurSvg } from '../shared/lib/image-blur-svg'; import { hasLocalMatch } from '../shared/lib/match-local-pattern'; import { hasRemoteMatch } from '../shared/lib/match-remote-pattern'; import { MockedRequest, MockedResponse } from './lib/mock-request'; import { CachedRouteKind, IncrementalCacheKind } from './response-cache'; import { sendEtagResponse } from './send-payload'; import { getContentType, getExtension } from './serve-static'; import * as Log from '../build/output/log'; import { isPrivateIp } from './is-private-ip'; import { getOrInitDiskLRU } from './lib/disk-lru-cache.external'; import { parseUrl, parseReqUrl } from '../lib/url'; import { InvariantError } from '../shared/lib/invariant-error'; import { lookup } from 'dns/promises'; import { isIP } from 'net'; import { ADDRCONFIG } from 'dns'; import { pipeline, Transform } from 'stream'; import { constants as zlibConstants, createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from 'zlib'; import { imageOptimizerTransform } from './image-optimizer/transform'; import { extractEtag, getHash } from './image-optimizer/extract-etag'; import { getPreviouslyCachedImageOrNull } from './image-optimizer/get-previously-cached-image-or-null'; import { getImageSize } from './image-optimizer/get-image-size'; import { ImageError } from './image-optimizer/image-error'; // `next-server.ts` destructures `ImageError` from this module to check // `instanceof` on errors thrown by the transform. export { ImageError } from './image-optimizer/image-error'; const CACHE_VERSION = 4; const BLUR_IMG_SIZE = 8 // should match `next-image-loader` ; const BLUR_QUALITY = 70 // should match `next-image-loader` ; async function initCacheEntries(cacheDir) { const cacheKeys = await promises.readdir(cacheDir).catch(()=>[]); const entries = []; for (const cacheKey of cacheKeys){ try { const { expireAt, buffer } = await readFromCacheDir(cacheDir, cacheKey); entries.push({ key: cacheKey, size: buffer.byteLength, expireAt }); } catch { // Skip entries that can't be read from disk } } // Sort oldest-first so we can replay them chronologically into LRU return entries.sort((a, b)=>a.expireAt - b.expireAt); } function getSupportedMimeType(options, accept = '') { const mimeType = mediaType(accept, options); return accept.includes(mimeType) ? mimeType : ''; } async function writeToCacheDir(cacheDir, cacheKey, extension, maxAge, expireAt, buffer, etag, upstreamEtag) { if (buffer.byteLength === 0) { throw new Error('Invariant: cannot write an empty buffer to the image cache'); } const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey); const filename = join(/* turbopackIgnore: true */ dir, `${maxAge}.${expireAt}.${etag}.${upstreamEtag}.${extension}`); await promises.rm(dir, { recursive: true, force: true }).catch(()=>{}); await promises.mkdir(dir, { recursive: true }); await promises.writeFile(filename, buffer); } async function readFromCacheDir(cacheDir, cacheKey) { const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey); const files = await promises.readdir(dir); const file = files[0]; if (!file) { throw new Error(`Invariant: cache entry "${cacheKey}" not found in dir "${cacheDir}"`); } const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] = file.split('.', 5); const filePath = join(/* turbopackIgnore: true */ dir, file); const buffer = await promises.readFile(/* turbopackIgnore: true */ filePath); if (buffer.byteLength === 0) { throw new Error(`Invariant: image cache entry "${cacheKey}" is empty`); } const expireAt = Number(expireAtSt); const maxAge = Number(maxAgeSt); return { maxAge, expireAt, etag, upstreamEtag, buffer, extension }; } async function deleteFromCacheDir(cacheDir, cacheKey) { return promises.rm(join(/* turbopackIgnore: true */ cacheDir, cacheKey), { recursive: true, force: true }).catch((err)=>{ Log.error(`Failed to delete cache key ${cacheKey}`, err); }); } export class ImageOptimizerCache { static validateParams(req, query, nextConfig, isDev) { var _nextConfig_images, _nextConfig_images1, _nextConfig_images2; const imageData = nextConfig.images; const { deviceSizes = [], imageSizes = [], domains = [], minimumCacheTTL = 14400, formats = [ 'image/webp' ] } = imageData; const remotePatterns = ((_nextConfig_images = nextConfig.images) == null ? void 0 : _nextConfig_images.remotePatterns) || []; const localPatterns = (_nextConfig_images1 = nextConfig.images) == null ? void 0 : _nextConfig_images1.localPatterns; const qualities = (_nextConfig_images2 = nextConfig.images) == null ? void 0 : _nextConfig_images2.qualities; const { url, w, q } = query; let href; if (domains.length > 0) { Log.warnOnce('The "images.domains" configuration is deprecated. Please use "images.remotePatterns" configuration instead.'); } if (!url) { return { errorMessage: '"url" parameter is required' }; } else if (Array.isArray(url)) { return { errorMessage: '"url" parameter cannot be an array' }; } if (url.length > 3072) { return { errorMessage: '"url" parameter is too long' }; } if (url.startsWith('//')) { return { errorMessage: '"url" parameter cannot be a protocol-relative URL (//)' }; } let isAbsolute; if (url.startsWith('/')) { var _parseUrl; href = url; isAbsolute = false; if (/\/_next\/image($|\/)/.test(decodeURIComponent(((_parseUrl = parseUrl(url)) == null ? void 0 : _parseUrl.pathname) ?? ''))) { return { errorMessage: '"url" parameter cannot be recursive' }; } if (!hasLocalMatch(localPatterns, url)) { return { errorMessage: '"url" parameter is not allowed' }; } } 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 (!hasRemoteMatch(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' }; } else if (!/^[0-9]+$/.test(w)) { return { errorMessage: '"w" parameter (width) must be an integer greater than 0' }; } if (!q) { return { errorMessage: '"q" parameter (quality) is required' }; } else if (Array.isArray(q)) { return { errorMessage: '"q" parameter (quality) cannot be an array' }; } else if (!/^[0-9]+$/.test(q)) { return { errorMessage: '"q" parameter (quality) must be an integer between 1 and 100' }; } const width = parseInt(w, 10); if (width <= 0 || isNaN(width)) { return { errorMessage: '"w" parameter (width) must be an integer 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, 10); if (isNaN(quality) || quality < 1 || quality > 100) { return { errorMessage: '"q" parameter (quality) must be an integer between 1 and 100' }; } if (qualities && !qualities.includes(quality) && !(isDev && quality === BLUR_QUALITY)) { return { errorMessage: `"q" parameter (quality) of ${q} is not allowed` }; } const mimeType = getSupportedMimeType(formats || [], req.headers['accept']); const isStatic = url.startsWith(`${nextConfig.basePath || ''}/_next/static/media`) || url.startsWith(`${nextConfig.basePath || ''}/_next/static/immutable/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, cacheHandler }){ this.cacheDir = join(/* turbopackIgnore: true */ distDir, 'cache', 'images'); this.nextConfig = nextConfig; this.cacheHandler = cacheHandler; // Eagerly start LRU initialization for filesystem cache if (!cacheHandler && nextConfig.images.maximumDiskCacheSize !== 0 && nextConfig.experimental.isrFlushToDisk) { this.isDiskCacheEnabled = true; this.cacheDiskLRU = getOrInitDiskLRU(this.cacheDir, nextConfig.images.maximumDiskCacheSize, initCacheEntries, deleteFromCacheDir); } } async get(cacheKey) { // If a custom cache handler is provided, use it if (this.cacheHandler) { try { const cacheData = await this.cacheHandler.get(cacheKey, { kind: IncrementalCacheKind.IMAGE, isFallback: false }); if (!(cacheData == null ? void 0 : cacheData.value)) { return null; } if (cacheData.value.kind !== CachedRouteKind.IMAGE) { return null; } const now = Date.now(); const lastModified = cacheData.lastModified || now; const revalidate = typeof cacheData.value.revalidate === 'number' ? cacheData.value.revalidate : this.nextConfig.images.minimumCacheTTL; const revalidateAfter = Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 + lastModified; const isStale = revalidateAfter < now; return { value: cacheData.value, revalidateAfter, cacheControl: { revalidate, expire: undefined }, isStale }; } catch (_) { // failed to get from custom cache handler, treat as cache miss } return null; } // If the filesystem cache is disabled, return early if (!this.isDiskCacheEnabled) { return null; } // Fall back to filesystem cache try { const now = Date.now(); const { maxAge, expireAt, etag, upstreamEtag, buffer, extension } = await readFromCacheDir(this.cacheDir, cacheKey); // Promote entry in LRU (mark as recently used) const lru = await this.cacheDiskLRU; lru == null ? void 0 : lru.get(cacheKey); return { value: { kind: CachedRouteKind.IMAGE, etag, buffer, extension, upstreamEtag }, revalidateAfter: Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now(), cacheControl: { revalidate: maxAge, expire: undefined }, isStale: now > expireAt }; } catch (_) { // failed to read from cache dir, treat as cache miss } return null; } async set(cacheKey, value, { cacheControl }) { if ((value == null ? void 0 : value.kind) !== CachedRouteKind.IMAGE) { throw new Error('invariant attempted to set non-image to image-cache'); } const revalidate = cacheControl == null ? void 0 : cacheControl.revalidate; if (typeof revalidate !== 'number') { throw new InvariantError('revalidate must be a number for image-cache'); } // If a custom cache handler is provided, use it if (this.cacheHandler) { try { // Apply minimumCacheTTL at write time, similar to the implementation in the fallback filesystem cache const effectiveRevalidate = Math.max(revalidate, this.nextConfig.images.minimumCacheTTL); const valueWithRevalidate = { ...value, revalidate: effectiveRevalidate }; await this.cacheHandler.set(cacheKey, valueWithRevalidate, { kind: IncrementalCacheKind.IMAGE, cacheControl: { revalidate: effectiveRevalidate, expire: cacheControl == null ? void 0 : cacheControl.expire } }); } catch (err) { Log.error(`Failed to write image to custom cache ${cacheKey}`, err); } return; } // If the filesystem cache is disabled, return early if (!this.isDiskCacheEnabled) { return; } // Fall back to filesystem cache const expireAt = Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now(); try { const lru = await this.cacheDiskLRU; const success = lru == null ? void 0 : lru.set(cacheKey, value.buffer.byteLength); if (success === false) { throw new Error(`image of size ${value.buffer.byteLength} could not be tracked by lru cache`); } await writeToCacheDir(this.cacheDir, cacheKey, value.extension, revalidate, expireAt, value.buffer, value.etag, value.upstreamEtag); } catch (err) { Log.error(`Failed to write image to cache ${cacheKey}`, err); } } } function isRedirect(statusCode) { return [ 301, 302, 303, 307, 308 ].includes(statusCode); } function upstreamTimedOut(href) { Log.error('upstream image response timed out for', href); return new ImageError(504, '"url" parameter is valid but upstream response timed out'); } const agentOptions = { keepAlive: true, timeout: 7000 }; const getHttpAgentName = HttpAgent.prototype.getName; const getHttpsAgentName = HttpsAgent.prototype.getName; class ImageHttpAgent extends HttpAgent { getName(options = {}) { return `${getHttpAgentName.call(this, options)}:${options.imageLookupKey ?? ''}`; } } class ImageHttpsAgent extends HttpsAgent { getName(options = {}) { return `${getHttpsAgentName.call(this, options)}:${options.imageLookupKey ?? ''}`; } } let protectedHttpAgent; let protectedHttpsAgent; let permissiveHttpAgent; let permissiveHttpsAgent; function getImageAgent(dangerouslyAllowLocalIP, isHttps) { if (isHttps) { if (dangerouslyAllowLocalIP) { return permissiveHttpsAgent ??= new ImageHttpsAgent(agentOptions); } return protectedHttpsAgent ??= new ImageHttpsAgent(agentOptions); } if (dangerouslyAllowLocalIP) { return permissiveHttpAgent ??= new ImageHttpAgent(agentOptions); } return protectedHttpAgent ??= new ImageHttpAgent(agentOptions); } const BASE_REQ_HEADERS = { accept: '*/*', 'accept-language': '*', 'sec-fetch-mode': 'cors', 'user-agent': 'node' }; const HTTP_FETCH_HEADERS = { ...BASE_REQ_HEADERS, 'accept-encoding': 'gzip, deflate' }; const HTTPS_FETCH_HEADERS = { ...BASE_REQ_HEADERS, 'accept-encoding': 'br, gzip, deflate' }; /** * Resolves `hostname` with the DNS options the socket itself would have used, * so the addresses checked below are the exact set the connection can reach. * Mirrors `lookupAndConnect()` in Node's `lib/net.js`. */ function lookupAsSocketWould(hostname) { return lookup(hostname, { all: true, // Node applies ADDRCONFIG whenever no address family is requested, // on every platform except Windows. hints: process.platform === 'win32' ? 0 : ADDRCONFIG }); } /** * Pins the connection to `addresses` instead of letting it resolve `hostname` * a second time. Checking addresses up front only proves where the name * pointed at that moment; the socket runs its own lookup, so a record that * changes in between (DNS rebinding) would otherwise still reach a private IP. * * Every address in the list was checked, so all of them are handed back to * keep Node's happy eyeballs failover between IPv6 and IPv4 intact. */ export function createPinnedLookup(hostname, addresses) { return (lookupHostname, options, callback)=>{ if (lookupHostname !== hostname) { callback(new InvariantError(`Image lookup hostname "${lookupHostname}" does not match request hostname "${hostname}"`), []); return; } const family = typeof options.family === 'number' ? options.family : 0; const matched = family ? addresses.filter((address)=>address.family === family) : addresses; if (matched.length === 0) { // Reporting this as an error rather than an empty list is not optional: // Node destructures the first address, so the resulting TypeError is // thrown inside the socket and escapes the request entirely. const err = new Error(`No IPv${family} address available for ${hostname}`); err.code = 'ENODATA'; callback(err, []); return; } if (options.all) { callback(null, matched); } else { callback(null, matched[0].address, matched[0].family); } }; } function requestUpstreamImage(url, pinnedLookup, imageLookupKey, dangerouslyAllowLocalIP, signal) { return new Promise((resolve, reject)=>{ const isHttps = url.protocol === 'https:'; const request = isHttps ? httpsRequest : httpRequest; const options = { agent: getImageAgent(dangerouslyAllowLocalIP, isHttps), headers: isHttps ? HTTPS_FETCH_HEADERS : HTTP_FETCH_HEADERS, imageLookupKey, lookup: pinnedLookup, signal }; const req = request(url, options, resolve); req.on('error', reject); req.end(); }); } class DeflateDecoder extends Transform { constructor(){ super(); this.decoder = undefined; } _transform(chunk, encoding, callback) { if (!this.decoder) { if (chunk.length === 0) { callback(); return; } const options = { flush: zlibConstants.Z_SYNC_FLUSH, finishFlush: zlibConstants.Z_SYNC_FLUSH }; this.decoder = (chunk[0] & 0x0f) === 0x08 ? createInflate(options) : createInflateRaw(options); this.decoder.on('data', (data)=>this.push(data)); this.decoder.on('end', ()=>this.push(null)); this.decoder.on('error', (err)=>this.destroy(err)); } this.decoder.write(chunk, encoding, callback); } _final(callback) { var _this_decoder; (_this_decoder = this.decoder) == null ? void 0 : _this_decoder.end(); this.decoder = undefined; callback(); } } // Matches the chain limit of native fetch (undici), so an upstream cannot // force unbounded decoder allocations with the header alone. const MAX_CONTENT_ENCODINGS = 5; function decodeResponseBody(res, href) { const contentEncoding = res.headers['content-encoding']; if (!contentEncoding) { return res; } const codings = contentEncoding.toLowerCase().split(','); if (codings.length > MAX_CONTENT_ENCODINGS) { Log.error('upstream image response had too many content encodings for', href); throw new ImageError(400, '"url" parameter is valid but upstream response is invalid'); } const decoders = []; for(let i = codings.length - 1; i >= 0; i--){ switch(codings[i].trim()){ case 'x-gzip': case 'gzip': decoders.push(createGunzip({ flush: zlibConstants.Z_SYNC_FLUSH, finishFlush: zlibConstants.Z_SYNC_FLUSH })); break; case 'deflate': decoders.push(new DeflateDecoder()); break; case 'br': decoders.push(createBrotliDecompress({ flush: zlibConstants.BROTLI_OPERATION_FLUSH, finishFlush: zlibConstants.BROTLI_OPERATION_FLUSH })); break; default: return res; } } pipeline([ res, ...decoders ], ()=>{}); return decoders[decoders.length - 1]; } export async function fetchExternalImage(href, dangerouslyAllowLocalIP, maximumResponseBody, count = 3) { const url = new URL(href); // `URL.hostname` keeps the brackets around an IPv6 literal, the socket does not const hostname = url.hostname.replace(/^\[|\]$/g, ''); let pinnedLookup; let imageLookupKey; if (!dangerouslyAllowLocalIP) { const literalFamily = isIP(hostname); let addresses; if (literalFamily > 0) { // A literal address is connected to as-is, without any DNS resolution addresses = [ { address: hostname, family: literalFamily } ]; } else { addresses = await lookupAsSocketWould(hostname); pinnedLookup = createPinnedLookup(hostname, addresses); } imageLookupKey = addresses.map((address)=>`${address.family}:${address.address}`).join(','); const privateIps = addresses.map((record)=>record.address).filter((ip)=>isPrivateIp(ip)); if (privateIps.length > 0) { Log.error('upstream image', href, 'hostname resolved to private IP', JSON.stringify(privateIps), 'If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.'); throw new ImageError(400, '"url" parameter is not allowed'); } } // The signal is owned here rather than by `requestUpstreamImage()` because it // has to stay readable while the body streams. Node reports an abort during // the body as a plain `ECONNRESET` on the response, so the signal is the only // way to tell a timeout apart from the upstream resetting the connection. const signal = AbortSignal.timeout(agentOptions.timeout); let res; try { res = await requestUpstreamImage(url, pinnedLookup, imageLookupKey, dangerouslyAllowLocalIP, signal); } catch (err) { if (signal.aborted) { throw upstreamTimedOut(href); } throw err; } const statusCode = res.statusCode; if (statusCode === undefined) { res.destroy(); throw new InvariantError('Expected statusCode on upstream image response'); } const locationHeader = res.headers.location; if (isRedirect(statusCode) && locationHeader && URL.canParse(locationHeader, href)) { // Discard the body rather than draining it. A redirect body is never used, // and draining one would download unlimited bytes from the upstream because // `maximumResponseBody` is only enforced on the final response. res.destroy(); if (count === 0) { Log.error('upstream image response had too many redirects', href); throw new ImageError(508, '"url" parameter is valid but upstream response is invalid'); } const redirect = new URL(locationHeader, href).href; return fetchExternalImage(redirect, dangerouslyAllowLocalIP, maximumResponseBody, count - 1); } if (statusCode < 200 || statusCode > 299) { res.destroy(); Log.error('upstream image response failed for', href, statusCode); throw new ImageError(statusCode, '"url" parameter is valid but upstream response is invalid'); } const chunks = []; let totalSize = 0; try { const body = decodeResponseBody(res, href); // Throwing out of this loop destroys the response, so an oversized or // timed out download stops instead of running to completion. for await (const chunk of body){ totalSize += chunk.byteLength; if (totalSize > maximumResponseBody) { Log.error('upstream image response exceeded maximum size for', href, totalSize); throw new ImageError(413, '"url" parameter is valid but upstream response is invalid'); } chunks.push(chunk); } } catch (err) { res.destroy(); if (signal.aborted) { throw upstreamTimedOut(href); } throw err; } if (totalSize === 0) { Log.error('upstream image response is empty for', href); throw new ImageError(400, '"url" parameter is valid but upstream response is invalid'); } const buffer = Buffer.concat(chunks); const contentType = res.headers['content-type']; const cacheControl = res.headers['cache-control']; const etag = extractEtag(res.headers.etag, buffer); return { buffer, contentType, cacheControl, etag }; } export async function fetchInternalImage(href, _req, _res, maximumResponseBody, handleRequest) { try { // Coerce HEAD to GET to avoid issues with the image optimizer const method = !_req.method || _req.method === 'HEAD' ? 'GET' : _req.method; // The mocked request keeps the requester's socket so that protocol and // remote address detection keep working, but the mocked response must not // reference it. `send` (used by `serveStatic`) watches `res.socket` through // `on-finished` and treats the response as finished as soon as that socket // stops being writable. When the requester disconnected mid-transfer this // tore down the file stream without ever ending the mocked response, and // since `ResponseCache` coalesces every request for the same cache key onto // this single fetch, the key stayed pending for every later requester until // the server restarted. const mocked = { req: new MockedRequest({ url: href, method, headers: {}, socket: _req.socket }), res: new MockedResponse({ maximumResponseBody }) }; await handleRequest(mocked.req, mocked.res, parseReqUrl(href)); await mocked.res.hasStreamed; if (!mocked.res.statusCode || mocked.res.statusCode < 200 || mocked.res.statusCode > 299) { Log.error('internal image response failed for', href, mocked.res.statusCode); throw new ImageError(mocked.res.statusCode, '"url" parameter is valid but internal response is invalid'); } if (mocked.res.buffers.length === 0) { Log.error('internal image response is empty for', href); throw new ImageError(400, '"url" parameter is valid but internal response is invalid'); } const buffer = Buffer.concat(mocked.res.buffers); const contentType = mocked.res.getHeader('Content-Type'); const cacheControl = mocked.res.getHeader('Cache-Control'); const etag = extractEtag(mocked.res.getHeader('ETag'), buffer); return { buffer, contentType, cacheControl, etag }; } catch (err) { if (err instanceof ImageError) { throw err; } if (err && typeof err === 'object' && 'code' in err && err.code === 'ERR_MAX_BODY_SIZE_EXCEEDED') { Log.error('internal image response exceeded maximum size for', href); throw new ImageError(413, '"url" parameter is valid but internal response is invalid'); } Log.error('upstream image response failed for', href, err); throw new ImageError(500, '"url" parameter is valid but upstream response is invalid'); } } async function makeBlurPlaceholder(buffer, contentType) { // 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 getImageSize(buffer); const blurOpts = { blurWidth: meta.width, blurHeight: meta.height, blurDataURL: `data:${contentType};base64,${buffer.toString('base64')}` }; return { buffer: Buffer.from(unescape(getImageBlurSvg(blurOpts))), contentType: 'image/svg+xml' }; } export async function imageOptimizer(imageUpstream, paramsResult, nextConfig, opts) { var _opts_previousCacheEntry_cacheControl, _opts_previousCacheEntry; const previouslyCachedImage = getPreviouslyCachedImageOrNull(imageUpstream, opts.previousCacheEntry); return imageOptimizerTransform(imageUpstream, paramsResult, nextConfig, { previousOutput: previouslyCachedImage ? { buffer: previouslyCachedImage.buffer, maxAge: ((_opts_previousCacheEntry = opts.previousCacheEntry) == null ? void 0 : (_opts_previousCacheEntry_cacheControl = _opts_previousCacheEntry.cacheControl) == null ? void 0 : _opts_previousCacheEntry_cacheControl.revalidate) || undefined, etag: previouslyCachedImage.etag, upstreamEtag: previouslyCachedImage.upstreamEtag } : undefined, logger: opts.silent ? undefined : Log, handleDevOutput: opts.isDev && paramsResult.width <= BLUR_IMG_SIZE && paramsResult.quality === BLUR_QUALITY ? makeBlurPlaceholder : undefined }); } function getFileNameWithExtension(url, contentType) { const [urlWithoutQueryParams] = url.split('?', 1); const fileNameWithExtension = urlWithoutQueryParams.split('/').pop(); if (!contentType || !fileNameWithExtension) { return 'image.bin'; } const [fileName] = fileNameWithExtension.split('.', 1); 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, etag, isStatic, xCache, imagesConfig, maxAge, isDev) { const contentType = getContentType(extension); const result = setResponseHeaders(req, res, url, etag, contentType, isStatic, xCache, imagesConfig, maxAge, isDev); if (!result.finished) { res.setHeader('Content-Length', Buffer.byteLength(buffer)); // A response body must not be sent for HEAD requests if (req.method === 'HEAD') { res.end(); } else { res.end(buffer); } } } //# sourceMappingURL=image-optimizer.js.map