Skip to content

Commit 088f267

Browse files
feat: next/image improvements (sharp + on demand builders) (#295)
1 parent 1890684 commit 088f267

14 files changed

+663
-1294
lines changed

package-lock.json

+550-1,247
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+5-2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
},
5555
"homepage": "https://github.com/netlify/netlify-plugin-nextjs#readme",
5656
"dependencies": {
57+
"@netlify/functions": "^0.6.0",
5758
"@sls-next/lambda-at-edge": "1.8.0",
5859
"adm-zip": "^0.5.4",
5960
"chalk": "^4.1.0",
@@ -64,10 +65,12 @@
6465
"find-cache-dir": "^3.3.1",
6566
"find-up": "^5.0.0",
6667
"fs-extra": "^9.1.0",
67-
"jimp": "^0.16.1",
6868
"make-dir": "^3.1.0",
69+
"mime-types": "^2.1.30",
6970
"moize": "^6.0.0",
70-
"semver": "^7.3.2"
71+
"node-fetch": "^2.6.1",
72+
"semver": "^7.3.2",
73+
"sharp": "^0.28.1"
7174
},
7275
"devDependencies": {
7376
"@netlify/eslint-config-node": "^2.6.7",

src/lib/config.js

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const TEMPLATES_DIR = join(__dirname, 'templates')
2626
// This is the Netlify Function template that wraps all SSR pages
2727
const FUNCTION_TEMPLATE_PATH = join(TEMPLATES_DIR, 'netlifyFunction.js')
2828

29+
// This is the Netlify Builder template that wraps ISR pages
30+
const BUILDER_TEMPLATE_PATH = join(TEMPLATES_DIR, 'netlifyOnDemandBuilder.js')
31+
2932
// This is the file where custom redirects can be configured
3033
const CUSTOM_REDIRECTS_PATH = join('.', '_redirects')
3134

@@ -45,6 +48,7 @@ module.exports = {
4548
NEXT_CONFIG_PATH,
4649
TEMPLATES_DIR,
4750
FUNCTION_TEMPLATE_PATH,
51+
BUILDER_TEMPLATE_PATH,
4852
CUSTOM_REDIRECTS_PATH,
4953
CUSTOM_HEADERS_PATH,
5054
NEXT_IMAGE_FUNCTION_NAME,

src/lib/helpers/setupNetlifyFunctionForPage.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ const { join } = require('path')
22

33
const { copySync } = require('fs-extra')
44

5-
const { TEMPLATES_DIR, FUNCTION_TEMPLATE_PATH } = require('../config')
5+
const { TEMPLATES_DIR, FUNCTION_TEMPLATE_PATH, BUILDER_TEMPLATE_PATH } = require('../config')
66

77
const copyDynamicImportChunks = require('./copyDynamicImportChunks')
88
const getNetlifyFunctionName = require('./getNetlifyFunctionName')
99
const getNextDistDir = require('./getNextDistDir')
1010
const { logItem } = require('./logger')
1111

1212
// Create a Netlify Function for the page with the given file path
13-
const setupNetlifyFunctionForPage = async ({ filePath, functionsPath, isApiPage }) => {
13+
const setupNetlifyFunctionForPage = async ({ filePath, functionsPath, isApiPage, isISR }) => {
1414
// Set function name based on file path
1515
const functionName = getNetlifyFunctionName(filePath, isApiPage)
1616
const functionDirectory = join(functionsPath, functionName)
@@ -21,13 +21,14 @@ const setupNetlifyFunctionForPage = async ({ filePath, functionsPath, isApiPage
2121

2222
// Copy function templates
2323
const functionTemplateCopyPath = join(functionDirectory, `${functionName}.js`)
24-
copySync(FUNCTION_TEMPLATE_PATH, functionTemplateCopyPath, {
24+
const srcTemplatePath = isISR ? BUILDER_TEMPLATE_PATH : FUNCTION_TEMPLATE_PATH
25+
copySync(srcTemplatePath, functionTemplateCopyPath, {
2526
overwrite: false,
2627
errorOnExist: true,
2728
})
2829

2930
// Copy function helpers
30-
const functionHelpers = ['renderNextPage.js', 'createRequestObject.js', 'createResponseObject.js']
31+
const functionHelpers = ['functionBase.js', 'renderNextPage.js', 'createRequestObject.js', 'createResponseObject.js']
3132
functionHelpers.forEach((helper) => {
3233
copySync(join(TEMPLATES_DIR, helper), join(functionDirectory, helper), {
3334
overwrite: false,

src/lib/pages/getStaticPropsWithFallback/setup.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const setup = async (functionsPath) => {
1818
const relativePath = getFilePathForRoute(route, 'js')
1919
const filePath = join('pages', relativePath)
2020
logItem(filePath)
21-
await setupNetlifyFunctionForPage({ filePath, functionsPath })
21+
await setupNetlifyFunctionForPage({ filePath, functionsPath, isISR: true })
2222
})
2323
}
2424

src/lib/steps/setupRedirects.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,16 @@ const setupRedirects = async (publishPath) => {
4747
const staticRedirects = nextRedirects.filter(({ route }) => !isDynamicRoute(removeFileExtension(route)))
4848
const dynamicRedirects = nextRedirects.filter(({ route }) => isDynamicRoute(removeFileExtension(route)))
4949

50-
// Add next/image redirect to our image function
50+
// Add necessary next/image redirects for our image function
5151
dynamicRedirects.push({
5252
route: '/_next/image* url=:url w=:width q=:quality',
53-
target: `/.netlify/functions/${NEXT_IMAGE_FUNCTION_NAME}?url=:url&w=:width&q=:quality`,
53+
target: `/nextimg/:url/:width/:quality`,
54+
statusCode: '301',
55+
force: true,
56+
})
57+
dynamicRedirects.push({
58+
route: '/nextimg/*',
59+
target: `/.netlify/functions/${NEXT_IMAGE_FUNCTION_NAME}`,
5460
})
5561

5662
const sortedStaticRedirects = getSortedRedirects(staticRedirects)

src/lib/templates/functionBase.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// TEMPLATE: This file will be copied to the Netlify functions directory when
2+
// running next-on-netlify
3+
4+
// Render function for the Next.js page
5+
const renderNextPage = require('./renderNextPage')
6+
7+
const base = async (event, context, callback) => {
8+
// x-forwarded-host is undefined on Netlify for proxied apps that need it
9+
// fixes https://github.com/netlify/next-on-netlify/issues/46
10+
if (!event.multiValueHeaders.hasOwnProperty('x-forwarded-host')) {
11+
event.multiValueHeaders['x-forwarded-host'] = [event.headers['host']]
12+
}
13+
14+
// Get the request URL
15+
const { path } = event
16+
console.log('[request]', path)
17+
18+
// Render the Next.js page
19+
const response = await renderNextPage({ event, context })
20+
21+
// Convert header values to string. Netlify does not support integers as
22+
// header values. See: https://github.com/netlify/cli/issues/451
23+
Object.keys(response.multiValueHeaders).forEach((key) => {
24+
response.multiValueHeaders[key] = response.multiValueHeaders[key].map((value) => String(value))
25+
})
26+
27+
response.multiValueHeaders['Cache-Control'] = ['no-cache']
28+
29+
callback(null, response)
30+
}
31+
32+
module.exports = base

src/lib/templates/imageFunction.js

+40-8
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,56 @@
1-
const jimp = require('jimp')
1+
const path = require('path')
2+
const { builder } = require('@netlify/functions')
3+
const sharp = require('sharp')
4+
const fetch = require('node-fetch')
25

36
// Function used to mimic next/image and sharp
4-
exports.handler = async (event) => {
5-
const { url, w = 500, q = 75 } = event.queryStringParameters
7+
const handler = async (event) => {
8+
const [, , url, w = 500, q = 75] = event.path.split('/')
9+
const parsedUrl = decodeURIComponent(url)
610
const width = parseInt(w)
711
const quality = parseInt(q)
812

9-
const imageUrl = url.startsWith('/') ? `${process.env.DEPLOY_URL || `http://${event.headers.host}`}${url}` : url
10-
const image = await jimp.read(imageUrl)
13+
const imageUrl = parsedUrl.startsWith('/')
14+
? `${process.env.DEPLOY_URL || `http://${event.headers.host}`}${parsedUrl}`
15+
: parsedUrl
16+
const imageData = await fetch(imageUrl)
17+
const bufferData = await imageData.buffer()
18+
const ext = path.extname(imageUrl)
19+
const mimeType = ext === 'jpg' ? `image/jpeg` : `image/${ext}`
1120

12-
image.resize(width, jimp.AUTO).quality(quality)
21+
let image
22+
let imageBuffer
1323

14-
const imageBuffer = await image.getBufferAsync(image.getMIME())
24+
if (mimeType === 'image/gif') {
25+
image = await sharp(bufferData, { animated: true })
26+
// gif resizing in sharp seems unstable (https://github.com/lovell/sharp/issues/2275)
27+
imageBuffer = await image.toBuffer()
28+
} else {
29+
image = await sharp(bufferData)
30+
if (mimeType === 'image/webp') {
31+
image = image.webp({ quality })
32+
} else if (mimeType === 'image/jpeg') {
33+
image = image.jpeg({ quality })
34+
} else if (mimeType === 'image/png') {
35+
image = image.png({ quality })
36+
} else if (mimeType === 'image/avif') {
37+
image = image.avif({ quality })
38+
} else if (mimeType === 'image/tiff') {
39+
image = image.tiff({ quality })
40+
} else if (mimeType === 'image/heif') {
41+
image = image.heif({ quality })
42+
}
43+
imageBuffer = await image.resize(width).toBuffer()
44+
}
1545

1646
return {
1747
statusCode: 200,
1848
headers: {
19-
'Content-Type': image.getMIME(),
49+
'Content-Type': mimeType,
2050
},
2151
body: imageBuffer.toString('base64'),
2252
isBase64Encoded: true,
2353
}
2454
}
55+
56+
exports.handler = builder(handler)

src/lib/templates/netlifyFunction.js

+2-24
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,6 @@
33

44
// Render function for the Next.js page
55
const renderNextPage = require('./renderNextPage')
6+
const functionBase = require('./functionBase')
67

7-
exports.handler = async (event, context, callback) => {
8-
// x-forwarded-host is undefined on Netlify for proxied apps that need it
9-
// fixes https://github.com/netlify/next-on-netlify/issues/46
10-
if (!event.multiValueHeaders.hasOwnProperty('x-forwarded-host')) {
11-
event.multiValueHeaders['x-forwarded-host'] = [event.headers['host']]
12-
}
13-
14-
// Get the request URL
15-
const { path } = event
16-
console.log('[request]', path)
17-
18-
// Render the Next.js page
19-
const response = await renderNextPage({ event, context })
20-
21-
// Convert header values to string. Netlify does not support integers as
22-
// header values. See: https://github.com/netlify/cli/issues/451
23-
Object.keys(response.multiValueHeaders).forEach((key) => {
24-
response.multiValueHeaders[key] = response.multiValueHeaders[key].map((value) => String(value))
25-
})
26-
27-
response.multiValueHeaders['Cache-Control'] = ['no-cache']
28-
29-
callback(null, response)
30-
}
8+
exports.handler = functionBase
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// TEMPLATE: This file will be copied to the Netlify functions directory when
2+
// running next-on-netlify
3+
4+
// Render on demand builder for the Next.js page
5+
const { builder } = require('@netlify/functions')
6+
const renderNextPage = require('./renderNextPage')
7+
const functionBase = require('./functionBase')
8+
9+
exports.handler = builder(functionBase)

src/tests/__snapshots__/defaults.test.js.snap

+2-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ exports[`Routing creates Netlify redirects 1`] = `
4646
/_next/data/%BUILD_ID%/getStaticProps/withFallback/:slug/* /.netlify/functions/next_getStaticProps_withFallback_slug 200
4747
/_next/data/%BUILD_ID%/getStaticProps/withFallbackBlocking/:id.json /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200
4848
/_next/data/%BUILD_ID%/getStaticProps/withRevalidate/withFallback/:id.json /.netlify/functions/next_getStaticProps_withRevalidate_withFallback_id 200
49-
/_next/image* url=:url w=:width q=:quality /.netlify/functions/next_image?url=:url&w=:width&q=:quality 200
49+
/_next/image* url=:url w=:width q=:quality /nextimg/:url/:width/:quality 301!
5050
/api/shows/:id /.netlify/functions/next_api_shows_id 200
5151
/api/shows/:params/* /.netlify/functions/next_api_shows_params 200
5252
/getServerSideProps/all /.netlify/functions/next_getServerSideProps_all_slug 200
@@ -56,6 +56,7 @@ exports[`Routing creates Netlify redirects 1`] = `
5656
/getStaticProps/withFallback/:slug/* /.netlify/functions/next_getStaticProps_withFallback_slug 200
5757
/getStaticProps/withFallbackBlocking/:id /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200
5858
/getStaticProps/withRevalidate/withFallback/:id /.netlify/functions/next_getStaticProps_withRevalidate_withFallback_id 200
59+
/nextimg/* /.netlify/functions/next_image 200
5960
/shows/:id /.netlify/functions/next_shows_id 200
6061
/shows/:params/* /.netlify/functions/next_shows_params 200
6162
/static/:id /static/[id].html 200"

src/tests/__snapshots__/i18n.test.js.snap

+2-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ exports[`Routing creates Netlify redirects 1`] = `
9292
/_next/data/%BUILD_ID%/getStaticProps/withFallback/:slug/* /.netlify/functions/next_getStaticProps_withFallback_slug 200
9393
/_next/data/%BUILD_ID%/getStaticProps/withFallbackBlocking/:id.json /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200
9494
/_next/data/%BUILD_ID%/getStaticProps/withRevalidate/withFallback/:id.json /.netlify/functions/next_getStaticProps_withRevalidate_withFallback_id 200
95-
/_next/image* url=:url w=:width q=:quality /.netlify/functions/next_image?url=:url&w=:width&q=:quality 200
95+
/_next/image* url=:url w=:width q=:quality /nextimg/:url/:width/:quality 301!
9696
/api/shows/:id /.netlify/functions/next_api_shows_id 200
9797
/api/shows/:params/* /.netlify/functions/next_api_shows_params 200
9898
/en/getServerSideProps/all /.netlify/functions/next_getServerSideProps_all_slug 200
@@ -122,6 +122,7 @@ exports[`Routing creates Netlify redirects 1`] = `
122122
/getStaticProps/withFallback/:slug/* /.netlify/functions/next_getStaticProps_withFallback_slug 200
123123
/getStaticProps/withFallbackBlocking/:id /.netlify/functions/next_getStaticProps_withFallbackBlocking_id 200
124124
/getStaticProps/withRevalidate/withFallback/:id /.netlify/functions/next_getStaticProps_withRevalidate_withFallback_id 200
125+
/nextimg/* /.netlify/functions/next_image 200
125126
/shows/:id /.netlify/functions/next_shows_id 200
126127
/shows/:params/* /.netlify/functions/next_shows_params 200
127128
/static/:id /en/static/[id].html 200"

src/tests/__snapshots__/optionalCatchAll.test.js.snap

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ exports[`Routing creates Netlify redirects 1`] = `
66
/page /.netlify/functions/next_page 200
77
/_next/data/%BUILD_ID%/index.json /.netlify/functions/next_all 200
88
/_next/data/%BUILD_ID%/* /.netlify/functions/next_all 200
9-
/_next/image* url=:url w=:width q=:quality /.netlify/functions/next_image?url=:url&w=:width&q=:quality 200
9+
/_next/image* url=:url w=:width q=:quality /nextimg/:url/:width/:quality 301!
10+
/nextimg/* /.netlify/functions/next_image 200
1011
/ /.netlify/functions/next_all 200
1112
/_next/* /_next/:splat 200
1213
/* /.netlify/functions/next_all 200"

src/tests/preRenderedIndexPages.test.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ describe('Routing', () => {
6868

6969
// Check that no redirects are present
7070
expect(redirects[0]).toEqual('# Next-on-Netlify Redirects')
71-
expect(redirects[1]).toEqual(
72-
'/_next/image* url=:url w=:width q=:quality /.netlify/functions/next_image?url=:url&w=:width&q=:quality 200',
73-
)
71+
expect(redirects[1]).toEqual('/_next/image* url=:url w=:width q=:quality /nextimg/:url/:width/:quality 301!')
7472
})
7573
})

0 commit comments

Comments
 (0)