Skip to content

Commit

Permalink
Support for static image imports (vercel#24993)
Browse files Browse the repository at this point in the history
Co-authored-by: Steven <[email protected]>
Co-authored-by: Tim Neutkens <[email protected]>
Co-authored-by: Tim Neutkens <[email protected]>
  • Loading branch information
4 people authored Jun 4, 2021
1 parent 9d14dd8 commit 9b295f5
Show file tree
Hide file tree
Showing 18 changed files with 422 additions and 18 deletions.
10 changes: 10 additions & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: [
Expand Down
52 changes: 52 additions & 0 deletions packages/next/build/webpack/loaders/next-image-loader.js
Original file line number Diff line number Diff line change
@@ -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
78 changes: 73 additions & 5 deletions packages/next/client/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ImageLoaderProps = {
src: string
width: number
quality?: number
isStatic?: boolean
}

type DefaultImageLoaderProps = ImageLoaderProps & { root: string }
Expand Down Expand Up @@ -49,11 +50,44 @@ type PlaceholderValue = 'blur' | 'empty'

type ImgElementStyle = NonNullable<JSX.IntrinsicElements['img']['style']>

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
Expand Down Expand Up @@ -145,6 +179,7 @@ type GenImgAttrsData = {
unoptimized: boolean
layout: LayoutValue
loader: ImageLoader
isStatic?: boolean
width?: number
quality?: number
sizes?: string
Expand All @@ -164,6 +199,7 @@ function generateImgAttrs({
quality,
sizes,
loader,
isStatic,
}: GenImgAttrsData): GenImgAttrsResult {
if (unoptimized) {
return { src, srcSet: undefined, sizes: undefined }
Expand All @@ -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}`
)
Expand All @@ -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] }),
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -294,7 +359,6 @@ export default function Image({
)
}
}

let isLazy =
!priority && (loading === 'lazy' || typeof loading === 'undefined')
if (src && src.startsWith('data:')) {
Expand Down Expand Up @@ -442,6 +506,7 @@ export default function Image({
quality: qualityInt,
sizes,
loader,
isStatic,
})
}

Expand Down Expand Up @@ -569,6 +634,7 @@ function cloudinaryLoader({

function defaultLoader({
root,
isStatic,
src,
width,
quality,
Expand Down Expand Up @@ -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' : ''
}`
}
2 changes: 2 additions & 0 deletions packages/next/next-server/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type NextConfig = { [key: string]: any } & {
workerThreads?: boolean
pageEnv?: boolean
optimizeImages?: boolean
enableStaticImages?: boolean
optimizeCss?: boolean
scrollRestoration?: boolean
stats?: boolean
Expand Down Expand Up @@ -105,6 +106,7 @@ export const defaultConfig: NextConfig = {
workerThreads: false,
pageEnv: false,
optimizeImages: false,
enableStaticImages: false,
optimizeCss: false,
scrollRestoration: false,
stats: false,
Expand Down
26 changes: 20 additions & 6 deletions packages/next/next-server/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 7 additions & 1 deletion packages/next/next-server/server/lib/squoosh/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ 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']
const m = await p.instantiate()
return await m(image.data, image.width, image.height, {
...p.defaultOptions,
width,
height,
})
}

Expand Down
23 changes: 19 additions & 4 deletions packages/next/next-server/server/lib/squoosh/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
})
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 9b295f5

Please sign in to comment.