diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 0f5f5b4da54b8..b6182fbae4be5 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -964,6 +964,7 @@ export default async function getBaseWebpackConfig( 'error-loader', 'next-babel-loader', 'next-client-pages-loader', + 'next-image-loader', 'next-serverless-loader', 'noop-loader', 'next-style-loader', @@ -1012,6 +1013,15 @@ export default async function getBaseWebpackConfig( ] : defaultLoaders.babel, }, + ...(config.experimental.enableStaticImages + ? [ + { + test: /\.(png|svg|jpg|jpeg|gif|webp|ico|bmp)$/i, + loader: 'next-image-loader', + dependency: { not: ['url'] }, + }, + ] + : []), ].filter(Boolean), }, plugins: [ diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js new file mode 100644 index 0000000000000..7299c8afbb3e8 --- /dev/null +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -0,0 +1,52 @@ +import loaderUtils from 'next/dist/compiled/loader-utils' +import sizeOf from 'image-size' +import { processBuffer } from '../../../next-server/server/lib/squoosh/main' + +const PLACEHOLDER_SIZE = 6 + +async function nextImageLoader(content) { + const context = this.rootContext + const opts = { context, content } + const interpolatedName = loaderUtils.interpolateName( + this, + '/static/image/[path][name].[hash].[ext]', + opts + ) + + let extension = loaderUtils.interpolateName(this, '[ext]', opts) + if (extension === 'jpg') { + extension = 'jpeg' + } + + const imageSize = sizeOf(content) + let placeholder + if (extension === 'jpeg' || extension === 'png') { + // Shrink the image's largest dimension to 6 pixels + const resizeOperationOpts = + imageSize.width >= imageSize.height + ? { type: 'resize', width: PLACEHOLDER_SIZE } + : { type: 'resize', height: PLACEHOLDER_SIZE } + const resizedImage = await processBuffer( + content, + [resizeOperationOpts], + extension, + 0 + ) + placeholder = `data:image/${extension};base64,${resizedImage.toString( + 'base64' + )}` + } + + const stringifiedData = JSON.stringify({ + src: '/_next' + interpolatedName, + height: imageSize.height, + width: imageSize.width, + placeholder, + }) + + this.emitFile(interpolatedName, content, null) + + return `${'export default '} ${stringifiedData};` +} +export const raw = true +export default nextImageLoader diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index e41f5c4b94475..9033611d3690d 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -22,6 +22,7 @@ export type ImageLoaderProps = { src: string width: number quality?: number + isStatic?: boolean } type DefaultImageLoaderProps = ImageLoaderProps & { root: string } @@ -49,11 +50,44 @@ type PlaceholderValue = 'blur' | 'empty' type ImgElementStyle = NonNullable +interface StaticImageData { + src: string + height: number + width: number + placeholder?: string +} + +interface StaticRequire { + default: StaticImageData +} + +type StaticImport = StaticRequire | StaticImageData + +function isStaticRequire( + src: StaticRequire | StaticImageData +): src is StaticRequire { + return (src as StaticRequire).default !== undefined +} + +function isStaticImageData( + src: StaticRequire | StaticImageData +): src is StaticImageData { + return (src as StaticImageData).src !== undefined +} + +function isStaticImport(src: string | StaticImport): src is StaticImport { + return ( + typeof src === 'object' && + (isStaticRequire(src as StaticImport) || + isStaticImageData(src as StaticImport)) + ) +} + export type ImageProps = Omit< JSX.IntrinsicElements['img'], 'src' | 'srcSet' | 'ref' | 'width' | 'height' | 'loading' | 'style' > & { - src: string + src: string | StaticImport loader?: ImageLoader quality?: number | string priority?: boolean @@ -145,6 +179,7 @@ type GenImgAttrsData = { unoptimized: boolean layout: LayoutValue loader: ImageLoader + isStatic?: boolean width?: number quality?: number sizes?: string @@ -164,6 +199,7 @@ function generateImgAttrs({ quality, sizes, loader, + isStatic, }: GenImgAttrsData): GenImgAttrsResult { if (unoptimized) { return { src, srcSet: undefined, sizes: undefined } @@ -177,7 +213,7 @@ function generateImgAttrs({ srcSet: widths .map( (w, i) => - `${loader({ src, quality, width: w })} ${ + `${loader({ src, quality, isStatic, width: w })} ${ kind === 'w' ? w : i + 1 }${kind}` ) @@ -189,7 +225,7 @@ function generateImgAttrs({ // updated by React. That causes multiple unnecessary requests if `srcSet` // and `sizes` are defined. // This bug cannot be reproduced in Chrome or Firefox. - src: loader({ src, quality, width: widths[last] }), + src: loader({ src, quality, isStatic, width: widths[last] }), } } @@ -265,6 +301,35 @@ export default function Image({ if (!configEnableBlurryPlaceholder) { placeholder = 'empty' } + const isStatic = typeof src === 'object' + let staticSrc = '' + if (isStaticImport(src)) { + const staticImageData = isStaticRequire(src) ? src.default : src + + if (!staticImageData.src) { + throw new Error( + `An object should only be passed to the image component src parameter if it comes from a static image import. It must include src. Received ${JSON.stringify( + staticImageData + )}` + ) + } + if (staticImageData.placeholder) { + blurDataURL = staticImageData.placeholder + } + staticSrc = staticImageData.src + if (!layout || layout !== 'fill') { + height = height || staticImageData.height + width = width || staticImageData.width + if (!staticImageData.height || !staticImageData.width) { + throw new Error( + `An object should only be passed to the image component src parameter if it comes from a static image import. It must include height and width. Received ${JSON.stringify( + staticImageData + )}` + ) + } + } + } + src = (isStatic ? staticSrc : src) as string if (process.env.NODE_ENV !== 'production') { if (!src) { @@ -294,7 +359,6 @@ export default function Image({ ) } } - let isLazy = !priority && (loading === 'lazy' || typeof loading === 'undefined') if (src && src.startsWith('data:')) { @@ -442,6 +506,7 @@ export default function Image({ quality: qualityInt, sizes, loader, + isStatic, }) } @@ -569,6 +634,7 @@ function cloudinaryLoader({ function defaultLoader({ root, + isStatic, src, width, quality, @@ -616,5 +682,7 @@ function defaultLoader({ } } - return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}` + return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}${ + isStatic ? '&s=1' : '' + }` } diff --git a/packages/next/next-server/server/config-shared.ts b/packages/next/next-server/server/config-shared.ts index 2f90d201c3008..1bdb62f9e4fd1 100644 --- a/packages/next/next-server/server/config-shared.ts +++ b/packages/next/next-server/server/config-shared.ts @@ -44,6 +44,7 @@ export type NextConfig = { [key: string]: any } & { workerThreads?: boolean pageEnv?: boolean optimizeImages?: boolean + enableStaticImages?: boolean optimizeCss?: boolean scrollRestoration?: boolean stats?: boolean @@ -105,6 +106,7 @@ export const defaultConfig: NextConfig = { workerThreads: false, pageEnv: false, optimizeImages: false, + enableStaticImages: false, optimizeCss: false, scrollRestoration: false, stats: false, diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts index 527201f213c9b..9cb514b8ed3f0 100644 --- a/packages/next/next-server/server/image-optimizer.ts +++ b/packages/next/next-server/server/image-optimizer.ts @@ -46,7 +46,7 @@ export async function imageOptimizer( } const { headers } = req - const { url, w, q } = parsedUrl.query + const { url, w, q, s } = parsedUrl.query const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept) let href: string @@ -111,6 +111,14 @@ export async function imageOptimizer( return { finished: true } } + if (s && s !== '1') { + res.statusCode = 400 + res.end('"s" parameter must be "1" or omitted') + return { finished: true } + } + + const isStatic = !!s + const width = parseInt(w, 10) if (!width || isNaN(width)) { @@ -261,7 +269,7 @@ export async function imageOptimizer( ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer) if (vector || animate) { await writeToCacheDir(hashDir, upstreamType, expireAt, upstreamBuffer) - sendResponse(req, res, upstreamType, upstreamBuffer) + sendResponse(req, res, upstreamType, upstreamBuffer, isStatic) return { finished: true } } @@ -333,12 +341,12 @@ export async function imageOptimizer( if (optimizedBuffer) { await writeToCacheDir(hashDir, contentType, expireAt, optimizedBuffer) - sendResponse(req, res, contentType, optimizedBuffer) + sendResponse(req, res, contentType, optimizedBuffer, isStatic) } else { throw new Error('Unable to optimize buffer') } } catch (error) { - sendResponse(req, res, upstreamType, upstreamBuffer) + sendResponse(req, res, upstreamType, upstreamBuffer, isStatic) } return { finished: true } @@ -366,10 +374,16 @@ function sendResponse( req: IncomingMessage, res: ServerResponse, contentType: string | null, - buffer: Buffer + buffer: Buffer, + isStatic: boolean ) { const etag = getHash([buffer]) - res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate') + res.setHeader( + 'Cache-Control', + isStatic + ? 'public, immutable, max-age=315360000' + : 'public, max-age=0, must-revalidate' + ) if (sendEtagResponse(req, res, etag)) { return } diff --git a/packages/next/next-server/server/lib/squoosh/impl.ts b/packages/next/next-server/server/lib/squoosh/impl.ts index a814249ea89d3..b9efc9859114f 100644 --- a/packages/next/next-server/server/lib/squoosh/impl.ts +++ b/packages/next/next-server/server/lib/squoosh/impl.ts @@ -29,7 +29,12 @@ export async function rotate( return await m(image.data, image.width, image.height, { numRotations }) } -export async function resize(image: ImageData, width: number) { +type ResizeOpts = { image: ImageData } & ( + | { width: number; height?: never } + | { height: number; width?: never } +) + +export async function resize({ image, width, height }: ResizeOpts) { image = ImageData.from(image) const p = preprocessors['resize'] @@ -37,6 +42,7 @@ export async function resize(image: ImageData, width: number) { return await m(image.data, image.width, image.height, { ...p.defaultOptions, width, + height, }) } diff --git a/packages/next/next-server/server/lib/squoosh/main.ts b/packages/next/next-server/server/lib/squoosh/main.ts index 8fbcb89212a47..4b885764481ab 100644 --- a/packages/next/next-server/server/lib/squoosh/main.ts +++ b/packages/next/next-server/server/lib/squoosh/main.ts @@ -9,8 +9,7 @@ type RotateOperation = { } type ResizeOperation = { type: 'resize' - width: number -} +} & ({ width: number; height?: never } | { height: number; width?: never }) export type Operation = RotateOperation | ResizeOperation export type Encoding = 'jpeg' | 'png' | 'webp' @@ -38,8 +37,24 @@ export async function processBuffer( if (operation.type === 'rotate') { imageData = await worker.rotate(imageData, operation.numRotations) } else if (operation.type === 'resize') { - if (imageData.width && imageData.width > operation.width) { - imageData = await worker.resize(imageData, operation.width) + if ( + operation.width && + imageData.width && + imageData.width > operation.width + ) { + imageData = await worker.resize({ + image: imageData, + width: operation.width, + }) + } else if ( + operation.height && + imageData.height && + imageData.height > operation.height + ) { + imageData = await worker.resize({ + image: imageData, + height: operation.height, + }) } } } diff --git a/packages/next/package.json b/packages/next/package.json index 340364434d988..5b096c71fcf55 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -102,6 +102,7 @@ "raw-body": "2.4.1", "react-is": "16.13.1", "react-refresh": "0.8.3", + "image-size": "1.0.0", "stream-browserify": "3.0.0", "stream-http": "3.1.1", "string_decoder": "1.3.0", diff --git a/test/integration/image-component/default/components/TallImage.js b/test/integration/image-component/default/components/TallImage.js new file mode 100644 index 0000000000000..c0fbbcfe6d63c --- /dev/null +++ b/test/integration/image-component/default/components/TallImage.js @@ -0,0 +1,20 @@ +import React from 'react' +import Image from 'next/image' + +import testTall from './tall.png' + +const Page = () => { + return ( +
+

Static Image

+ +
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/components/tall.png b/test/integration/image-component/default/components/tall.png new file mode 100644 index 0000000000000..a792dda6c172f Binary files /dev/null and b/test/integration/image-component/default/components/tall.png differ diff --git a/test/integration/image-component/default/pages/static.js b/test/integration/image-component/default/pages/static.js new file mode 100644 index 0000000000000..b0ddba5fc0702 --- /dev/null +++ b/test/integration/image-component/default/pages/static.js @@ -0,0 +1,56 @@ +import React from 'react' +import testImg from '../public/foo/test-rect.jpg' +import Image from 'next/image' + +import testJPG from '../public/test.jpg' +import testPNG from '../public/test.png' +import testSVG from '../public/test.svg' +import testGIF from '../public/test.gif' +import testBMP from '../public/test.bmp' +import testICO from '../public/test.ico' +import testWEBP from '../public/test.webp' + +import TallImage from '../components/TallImage' +const testFiles = [ + testJPG, + testPNG, + testSVG, + testGIF, + testBMP, + testICO, + testWEBP, +] + +const Page = () => { + return ( +
+

Static Image

+ + + + + + {testFiles.map((f, i) => ( + + ))} +
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/public/foo/test-rect.jpg b/test/integration/image-component/default/public/foo/test-rect.jpg new file mode 100644 index 0000000000000..68d3a8415f5e6 Binary files /dev/null and b/test/integration/image-component/default/public/foo/test-rect.jpg differ diff --git a/test/integration/image-component/default/public/test.ico b/test/integration/image-component/default/public/test.ico new file mode 100644 index 0000000000000..55cce0b4a8547 Binary files /dev/null and b/test/integration/image-component/default/public/test.ico differ diff --git a/test/integration/image-component/default/public/test.webp b/test/integration/image-component/default/public/test.webp new file mode 100644 index 0000000000000..4b306cb0898cc Binary files /dev/null and b/test/integration/image-component/default/public/test.webp differ diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index a1b034b36fb7b..bd9d79cbc95f2 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -553,21 +553,47 @@ function runTests(mode) { describe('Image Component Tests', () => { describe('dev mode', () => { beforeAll(async () => { + await fs.writeFile( + nextConfig, + ` + module.exports = { + experimental: { + enableStaticImages: true + }, + } + ` + ) appPort = await findPort() app = await launchApp(appDir, appPort) }) - afterAll(() => killApp(app)) + afterAll(async () => { + await fs.unlink(nextConfig) + await killApp(app) + }) runTests('dev') }) describe('server mode', () => { beforeAll(async () => { + await fs.writeFile( + nextConfig, + ` + module.exports = { + experimental: { + enableStaticImages: true + }, + } + ` + ) await nextBuild(appDir) appPort = await findPort() app = await nextStart(appDir, appPort) }) - afterAll(() => killApp(app)) + afterAll(async () => { + await fs.unlink(nextConfig) + await killApp(app) + }) runTests('server') }) @@ -581,6 +607,7 @@ describe('Image Component Tests', () => { target: 'serverless', experimental: { enableBlurryPlaceholder: true, + enableStaticImages: true }, } ` diff --git a/test/integration/image-component/default/test/static.test.js b/test/integration/image-component/default/test/static.test.js new file mode 100644 index 0000000000000..1c4e5e1a0b6d7 --- /dev/null +++ b/test/integration/image-component/default/test/static.test.js @@ -0,0 +1,102 @@ +import { + findPort, + killApp, + nextBuild, + nextStart, + renderViaHTTP, + File, +} from 'next-test-utils' +import webdriver from 'next-webdriver' +import { join } from 'path' + +jest.setTimeout(1000 * 30) + +const appDir = join(__dirname, '../') +let appPort +let app +let browser +let html + +const indexPage = new File(join(appDir, 'pages/static.js')) +const nextConfig = new File(join(appDir, 'next.config.js')) + +const runTests = () => { + it('Should allow an image with a static src to omit height and width', async () => { + expect(await browser.elementById('basic-static')).toBeTruthy() + expect(await browser.elementById('format-test-0')).toBeTruthy() + expect(await browser.elementById('format-test-1')).toBeTruthy() + expect(await browser.elementById('format-test-2')).toBeTruthy() + expect(await browser.elementById('format-test-3')).toBeTruthy() + expect(await browser.elementById('format-test-4')).toBeTruthy() + expect(await browser.elementById('format-test-5')).toBeTruthy() + expect(await browser.elementById('format-test-6')).toBeTruthy() + }) + it('Should automatically provide an image height and width', async () => { + expect(html).toContain('width:400px;height:300px') + }) + it('Should allow provided width and height to override intrinsic', async () => { + expect(html).toContain('width:200px;height:200px') + expect(html).not.toContain('width:400px;height:400px') + }) + it('Should append "&s=1" to URLs of static images', async () => { + expect( + await browser.elementById('basic-static').getAttribute('src') + ).toContain('&s=1') + }) + it('Should not append "&s=1" to URLs of non-static images', async () => { + expect( + await browser.elementById('basic-non-static').getAttribute('src') + ).not.toContain('&s=1') + }) + it('Should add a blurry placeholder to statically imported jpg', async () => { + expect(html).toContain( + `style="position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-image:url("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wEEEAMgAyADIAMgA1IDIAOEA+gD6AOEBOIFRgSwBUYE4gc6BqQGDgYOBqQHOgrwB9AIZgfQCGYH0ArwEJoKWgwcCloKWgwcCloQmg6mEcYOdA16DnQRxg6mGl4UtBJcElwUtBpeHngZlhg4GZYeeCTqIQIhAiTqLnwsJC58PL48vlGkEQMgAyADIAMgA1IDIAOEA+gD6AOEBOIFRgSwBUYE4gc6BqQGDgYOBqQHOgrwB9AIZgfQCGYH0ArwEJoKWgwcCloKWgwcCloQmg6mEcYOdA16DnQRxg6mGl4UtBJcElwUtBpeHngZlhg4GZYeeCTqIQIhAiTqLnwsJC58PL48vlGk/8IAEQgABgAGAwEiAAIRAQMRAf/EABQAAQAAAAAAAAAAAAAAAAAAAAH/2gAIAQEAAAAAP//EABQBAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIQAAAAf//EABQBAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMQAAAAf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Af//Z")"` + ) + }) + it('Should add a blurry placeholder to statically imported png', async () => { + expect(html).toContain( + `style="position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAGCAQAAABKxSfDAAAAOklEQVR42jXLsQ2AMADEQINYIT2ZgP2VfTLGmy+gObkxeImfhyXU1pSsrDoDPm53RfDOyKiE839y+gIFlSgsTCgClAAAAABJRU5ErkJggg==")"` + ) + }) +} + +describe('Build Error Tests', () => { + it('should throw build error when import statement is used with missing file', async () => { + await indexPage.replace( + '../public/foo/test-rect.jpg', + '../public/foo/test-rect-broken.jpg' + ) + + const { stderr } = await nextBuild(appDir, undefined, { stderr: true }) + await indexPage.restore() + + expect(stderr).toContain( + "Error: Can't resolve '../public/foo/test-rect-broken.jpg" + ) + }) +}) +describe('Static Image Component Tests', () => { + beforeAll(async () => { + nextConfig.write( + ` + module.exports = { + experimental: { + enableStaticImages: true, + enableBlurryPlaceholder: true, + }, + } + ` + ) + + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + html = await renderViaHTTP(appPort, '/static') + browser = await webdriver(appPort, '/static') + }) + afterAll(() => { + nextConfig.delete() + killApp(app) + }) + runTests() +}) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index d8b63b85a937b..db8bbe1fba44a 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -217,6 +217,13 @@ function runTests({ w, isDev, domains }) { ) }) + it('should fail when s is present and not "1"', async () => { + const query = { url: '/test.png', w, q: 100, s: 'foo' } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"s" parameter must be "1" or omitted`) + }) + it('should fail when domain is not defined in next.config.js', async () => { const url = `http://vercel.com/button` const query = { url, w, q: 100 } @@ -503,6 +510,16 @@ function runTests({ w, isDev, domains }) { expect(colorType).toBe(4) }) + it('should set cache-control to immutable for static images', async () => { + const query = { url: '/test.jpg', w, q: 100, s: '1' } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).toBe( + 'public, immutable, max-age=315360000' + ) + }) + it("should error if the resource isn't a valid image", async () => { const query = { url: '/test.txt', w, q: 80 } const opts = { headers: { accept: 'image/webp' } } diff --git a/yarn.lock b/yarn.lock index 2657e2392c3a1..b6cd8ed7d1133 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8738,6 +8738,13 @@ image-size@0.9.3: dependencies: queue "6.0.1" +image-size@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.0.0.tgz#58b31fe4743b1cec0a0ac26f5c914d3c5b2f0750" + integrity sha512-JLJ6OwBfO1KcA+TvJT+v8gbE6iWbj24LyDNFgFEN0lzegn6cC6a/p3NIDaepMsJjQjlUWqIC7wJv8lBFxPNjcw== + dependencies: + queue "6.0.2" + immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" @@ -13747,6 +13754,13 @@ queue@6.0.1: dependencies: inherits "~2.0.3" +queue@6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" + integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== + dependencies: + inherits "~2.0.3" + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"