From dab998532a9ed147646a43aa9b943dd6a02973de Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 30 Jun 2022 12:16:21 +0100 Subject: [PATCH 1/2] feat: use edge functions for content negotiation by default --- README.md | 90 +++++++++++++++++++------------- demos/canary/next.config.js | 3 ++ docs/isr.md | 26 ++++++--- plugin/src/helpers/edge.ts | 56 +++++++++++--------- plugin/src/helpers/functions.ts | 17 +++--- plugin/src/index.ts | 4 +- plugin/src/templates/edge/ipx.ts | 59 +++++++++++++++------ 7 files changed, 159 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 85deb76fed..dfd0d43a9c 100644 --- a/README.md +++ b/README.md @@ -37,42 +37,30 @@ If you build on Netlify, this plugin will work with no additional configuration. deploying locally using the Netlify CLI, you must deploy using `netlify deploy --build`. Running the build and deploy commands separately will not work, because the plugin will not generate the required configuration. -## Migrating from an older version of the plugin - -You can manually upgrade from the previous version of the plugin by running the following command: - -```shell -npm install -D @netlify/plugin-nextjs@latest -``` - -Change the `publish` directory to `.next`: - -```toml -[build] -publish = ".next" -``` - -If you previously set these values, they're no longer needed and can be removed: - -- `distDir` in your `next.config.js` -- `node_bundler = "esbuild"` in `netlify.toml` -- `external_node_modules` in `netlify.toml` - -The `serverless` and `experimental-serverless-trace` targets are deprecated in Next 12, and all builds with this plugin -will now use the default `server` target. If you previously set the target in your `next.config.js`, you should remove -it. - -If you currently use redirects or rewrites on your site, see -[the Rewrites and Redirects guide](https://github.com/netlify/netlify-plugin-nextjs/blob/main/docs/redirects-rewrites.md) -for information on changes to how they are handled in this version. In particular, note that `_redirects` and `_headers` -files must be placed in `public`, not in the root of the site. +## Using `next/image` + +If you use [`next/image`](https://nextjs.org/docs/basic-features/image-optimization), your images will be automatically +optimized at runtime, ensuring that they are served at the best size and format. The image will be processed on the +first request which means it may take longer to load, but the generated image is then cached at the edge and served as a +static file to future visitors. By default, Next will deliver WebP images if the browser supports it. WebP is a new +image format with wide browser support that will usually generate smaller files than png or jpg. You can additionally +enable the AVIF format, which is often even smaller in filesize than WebP. The drawback is that with particularly large +images AVIF may take too long to generate, meaning the function times-out. You can configure +[the supported image formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) in your +`next.config.js` file. + +In order to deliver the correct format to a visitor's browser, this uses a Netlify Edge Function. In some cases your +site may not support Edge Functions, in which case it will instead fall back to delivering the original file format. You +may also manually disable the Edge Function by setting the environment variable `NEXT_DISABLE_EDGE_IMAGES` to `true`. ## Next.js Middleware on Netlify Next.js Middleware works out of the box on Netlify, but check out the [docs on some caveats](https://github.com/netlify/netlify-plugin-nextjs/blob/main/docs/middleware.md). By default, middleware runs using SSR. For better results, you should enable [Netlify Edge Functions](#netlify-edge-functions), -which ensures middleware runs at the edge. +which ensures middleware runs at the edge. To use Netlify Edge Functions for middleware or to enable +[edge server rendering](https://nextjs.org/blog/next-12-2#edge-server-rendering-experimental), set the environment +variable `NEXT_USE_NETLIFY_EDGE` to `true`. ### No nested middleware in Next 12.2.0 @@ -80,13 +68,8 @@ In Next 12.2.0, nested middleware [has been deprecated](https://nextjs.org/docs/ favor of root level middleware. If you are not using edge functions then this means that you won't get the benefits of using a CDN, and ISR will not work. -To fix this issue, you can run your middleware on [Netlify Edge Functions](#netlify-edge-functions). - -## Netlify Edge Functions - -To use Netlify Edge Functions for middleware or to enable -[edge server rendering](https://nextjs.org/blog/next-12-2#edge-server-rendering-experimental), set the environment -variable `NEXT_USE_NETLIFY_EDGE` to `true`. +To fix this issue, you can run your middleware on [Netlify Edge Functions](#netlify-edge-functions) by setting the +environment variable `NEXT_USE_NETLIFY_EDGE` to `true`. ## Monorepos @@ -123,6 +106,39 @@ images). You can see the requests for these in [the function logs](https://docs. and fallback routes you will not see any requests that are served from the edge cache, just actual rendering requests. These are all internal functions, so you won't find them in your site's own functions directory. +The plugin will also generate a Netlify Edge Function called 'ipx' to handle image content negotiation, and if Edge +runtime or middleware is enabled it will also generate edge functions for middleware and edge routes. + +## Migrating from an older version of the plugin + +You can manually upgrade from the previous version of the plugin by running the following command: + +```shell +npm install -D @netlify/plugin-nextjs@latest +``` + +Change the `publish` directory to `.next`: + +```toml +[build] +publish = ".next" +``` + +If you previously set these values, they're no longer needed and can be removed: + +- `distDir` in your `next.config.js` +- `node_bundler = "esbuild"` in `netlify.toml` +- `external_node_modules` in `netlify.toml` + +The `serverless` and `experimental-serverless-trace` targets are deprecated in Next 12, and all builds with this plugin +will now use the default `server` target. If you previously set the target in your `next.config.js`, you should remove +it. + +If you currently use redirects or rewrites on your site, see +[the Rewrites and Redirects guide](https://github.com/netlify/netlify-plugin-nextjs/blob/main/docs/redirects-rewrites.md) +for information on changes to how they are handled in this version. In particular, note that `_redirects` and `_headers` +files must be placed in `public`, not in the root of the site. + ## Feedback If you think you have found a bug in the plugin, diff --git a/demos/canary/next.config.js b/demos/canary/next.config.js index a461e04b93..64a2da9236 100755 --- a/demos/canary/next.config.js +++ b/demos/canary/next.config.js @@ -6,6 +6,9 @@ const nextConfig = { // your project has ESLint errors. ignoreDuringBuilds: true, }, + images: { + formats: ['image/avif', 'image/webp'], + }, experimental: { images: { remotePatterns: [ diff --git a/docs/isr.md b/docs/isr.md index 99f3f42fa5..6ef0c8871d 100644 --- a/docs/isr.md +++ b/docs/isr.md @@ -21,11 +21,15 @@ request is made for stale content, the page will be regenerated in the backgroun but it can take up to 60 seconds before the new content is then updated in all CDN nodes if they already had a cached copy. -If the static regeneration relies on local files in your repository they need to be bundled with the handler functions. -This can be done by modifying your [file based configuration](https://docs.netlify.com/configure-builds/file-based-configuration). -An entry to the `included_files` option needs to be added under the `functions` option. You should be careful to not include unnecessary files, particularly large files such as images or videos, because there is a 50MB size limit for each handler function. -See [Functions Configuration Docs](https://docs.netlify.com/configure-builds/file-based-configuration/#functions) for more info. -Update your `netlify.toml` file to include the following (assuming local content is located in the /content directory): +If the static regeneration relies on local files in your repository they need to be bundled with the handler functions. +This can be done by modifying your +[file based configuration](https://docs.netlify.com/configure-builds/file-based-configuration). An entry to the +`included_files` option needs to be added under the `functions` option. You should be careful to not include unnecessary +files, particularly large files such as images or videos, because there is a 50MB size limit for each handler function. +See [Functions Configuration Docs](https://docs.netlify.com/configure-builds/file-based-configuration/#functions) for +more info. Update your `netlify.toml` file to include the following (assuming local content is located in the /content +directory): + ```toml [functions] included_files = ["content/**"] @@ -37,16 +41,24 @@ If you only need the content for DSG pages, then you can target only that functi [functions.__dsg] included_files = ["content/**"] ``` + or, for SSR pages: ```toml [functions.__ssr] included_files = ["content/**"] ``` + If a new deploy is made, all persisted pages and CDN cached pages will be invalidated so that conflicts are avoided. If this did not happen, a stale HTML page might make a request for an asset that no longer exists in the new deploy. By invalidating all persisted pages, you can be confident that this will never happen and that deploys remain atomic. +### On-demand ISR + +On-Demand ISR (where a path is manually revalidated) is not currently supported on Netlify. +[Please let us know](https://github.com/netlify/netlify-plugin-nextjs/discussions/1228) if this feature would be useful +to you, and if so how you would plan to use it. + ### Alternatives to ISR ISR is best for situations where there are regular updates to content throughout the day, particularly you don't have @@ -54,13 +66,13 @@ control over when it happens. It is less ideal in situations such as a CMS with the CMS trigger a deploy when a page is added or edited. This offers the best performance and avoids unnecesary rebuilds. -### Static site generation +#### Static site generation For high-traffic pages you can use [static generation](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) without `revalidate`, which deploys static files directly to the CDN for maximum performance. -### Distributed persistent rendering +#### Distributed persistent rendering For less commonly-accessed content you can use return `fallback: "blocking"` from [`getStaticPaths`](https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation) and defer diff --git a/plugin/src/helpers/edge.ts b/plugin/src/helpers/edge.ts index 4b1342511f..f9405c8f1b 100644 --- a/plugin/src/helpers/edge.ts +++ b/plugin/src/helpers/edge.ts @@ -125,12 +125,6 @@ const writeEdgeFunction = async ({ * Writes Edge Functions for the Next middleware */ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => { - const middlewareManifest = await loadMiddlewareManifest(netlifyConfig) - if (!middlewareManifest) { - console.error("Couldn't find the middleware manifest") - return - } - const manifest: FunctionManifest = { functions: [], version: 1, @@ -139,26 +133,27 @@ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => { const edgeFunctionRoot = resolve('.netlify', 'edge-functions') await emptyDir(edgeFunctionRoot) - await copyEdgeSourceFile({ edgeFunctionDir: edgeFunctionRoot, file: 'ipx.ts' }) - - manifest.functions.push({ - function: 'ipx', - path: '/_next/image*', - }) - - for (const middleware of middlewareManifest.sortedMiddleware) { - const edgeFunctionDefinition = middlewareManifest.middleware[middleware] - const functionDefinition = await writeEdgeFunction({ - edgeFunctionDefinition, - edgeFunctionRoot, - netlifyConfig, + if (!process.env.NEXT_DISABLE_EDGE_IMAGES) { + if (!process.env.NEXT_USE_NETLIFY_EDGE) { + console.log( + 'Using Netlify Edge Functions for image format detection. Set env var "NEXT_DISABLE_EDGE_IMAGES=true" to disable.', + ) + } + await copyEdgeSourceFile({ edgeFunctionDir: edgeFunctionRoot, file: 'ipx.ts' }) + manifest.functions.push({ + function: 'ipx', + path: '/_next/image*', }) - manifest.functions.push(functionDefinition) } - // Older versions of the manifest format don't have the functions field - // No, the version field was not incremented - if (typeof middlewareManifest.functions === 'object') { - for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) { + if (process.env.NEXT_USE_NETLIFY_EDGE) { + const middlewareManifest = await loadMiddlewareManifest(netlifyConfig) + if (!middlewareManifest) { + console.error("Couldn't find the middleware manifest") + return + } + + for (const middleware of middlewareManifest.sortedMiddleware) { + const edgeFunctionDefinition = middlewareManifest.middleware[middleware] const functionDefinition = await writeEdgeFunction({ edgeFunctionDefinition, edgeFunctionRoot, @@ -166,8 +161,19 @@ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => { }) manifest.functions.push(functionDefinition) } + // Older versions of the manifest format don't have the functions field + // No, the version field was not incremented + if (typeof middlewareManifest.functions === 'object') { + for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) { + const functionDefinition = await writeEdgeFunction({ + edgeFunctionDefinition, + edgeFunctionRoot, + netlifyConfig, + }) + manifest.functions.push(functionDefinition) + } + } } - await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest) } diff --git a/plugin/src/helpers/functions.ts b/plugin/src/helpers/functions.ts index 50fe41db97..a4696d8257 100644 --- a/plugin/src/helpers/functions.ts +++ b/plugin/src/helpers/functions.ts @@ -81,15 +81,14 @@ export const setupImageFunction = async ({ const imagePath = imageconfig.path || '/_next/image' - // If we have edge, we use content negotiation instead of the redirect - if (!process.env.NEXT_USE_NETLIFY_EDGE) { - netlifyConfig.redirects.push({ - from: `${imagePath}*`, - query: { url: ':url', w: ':width', q: ':quality' }, - to: `${basePath}/${IMAGE_FUNCTION_NAME}/w_:width,q_:quality/:url`, - status: 301, - }) - } + // If we have edge functions then the request will have already been rewritten + // so this won't match. This is matched if edge is disabled or unavailable. + netlifyConfig.redirects.push({ + from: `${imagePath}*`, + query: { url: ':url', w: ':width', q: ':quality' }, + to: `${basePath}/${IMAGE_FUNCTION_NAME}/w_:width,q_:quality/:url`, + status: 301, + }) netlifyConfig.redirects.push({ from: `${basePath}/${IMAGE_FUNCTION_NAME}/*`, diff --git a/plugin/src/index.ts b/plugin/src/index.ts index d6d18521c1..86a2b78a83 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -137,12 +137,14 @@ const plugin: NetlifyPlugin = { buildId, }) + // We call this even if we don't have edge functions enabled because we still use it for images + await writeEdgeFunctions(netlifyConfig) + if (process.env.NEXT_USE_NETLIFY_EDGE) { console.log(outdent` ✨ Deploying to ${greenBright`Netlify Edge Functions`} ✨ This feature is in beta. Please share your feedback here: https://ntl.fyi/next-netlify-edge `) - await writeEdgeFunctions(netlifyConfig) await updateConfig(publish) } diff --git a/plugin/src/templates/edge/ipx.ts b/plugin/src/templates/edge/ipx.ts index 3b132170a8..496dbcb840 100644 --- a/plugin/src/templates/edge/ipx.ts +++ b/plugin/src/templates/edge/ipx.ts @@ -1,32 +1,57 @@ -import { Accepts } from 'https://deno.land/x/accepts/mod.ts' -import type { Context } from 'netlify:edge' +import { Accepts } from "https://deno.land/x/accepts@2.1.1/mod.ts"; +import type { Context } from "netlify:edge"; +import imageconfig from "../functions-internal/_ipx/imageconfig.json" assert { + type: "json", +}; + +const defaultFormat = "webp" /** * Implement content negotiation for images */ +// deno-lint-ignore require-await const handler = async (req: Request, context: Context) => { - const { searchParams } = new URL(req.url) - const accept = new Accepts(req.headers) - const type = accept.types(['avif', 'webp']) + const { searchParams } = new URL(req.url); + console.log({imageconfig, headers: req.headers}) + const accept = new Accepts(req.headers); + const { formats = [defaultFormat] } = imageconfig; + if (formats.length === 0) { + formats.push(defaultFormat); + } + let type = accept.types(formats) + console.log('Accepted types:', type) + type ||= defaultFormat; + if(Array.isArray(type)) { + type = type[0]; + } - const source = searchParams.get('url') - const width = searchParams.get('w') - const quality = searchParams.get('q') ?? 75 + console.log('Resolved type to:', type) + + const source = searchParams.get("url"); + const width = searchParams.get("w"); + const quality = searchParams.get("q") ?? 75; if (!source || !width) { - return new Response('Invalid request', { + return new Response("Invalid request", { status: 400, - }) + }); } - const modifiers = [`w_${width}`, `q_${quality}`] + const modifiers = [`w_${width}`, `q_${quality}`]; if (type) { - modifiers.push(`f_${type}`) + if(type.includes('/')) { + // If this is a mimetype, strip "image/" + type = type.split('/')[1]; + } + modifiers.push(`f_${type}`); } - - return context.rewrite(`/_ipx/${modifiers.join(',')}/${encodeURIComponent(source)}`) -} - -export default handler + const target = `/_ipx/${modifiers.join(",")}/${encodeURIComponent(source)}`; + console.log('Loading image from', target) + return context.rewrite( + target, + ); +}; + +export default handler; From 0e1f88f43201f59a68c8515094872e06fda0defc Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 30 Jun 2022 14:13:04 +0100 Subject: [PATCH 2/2] chore: remove console.log --- plugin/src/templates/edge/ipx.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/plugin/src/templates/edge/ipx.ts b/plugin/src/templates/edge/ipx.ts index 496dbcb840..70e7ec605a 100644 --- a/plugin/src/templates/edge/ipx.ts +++ b/plugin/src/templates/edge/ipx.ts @@ -13,20 +13,16 @@ const defaultFormat = "webp" // deno-lint-ignore require-await const handler = async (req: Request, context: Context) => { const { searchParams } = new URL(req.url); - console.log({imageconfig, headers: req.headers}) const accept = new Accepts(req.headers); const { formats = [defaultFormat] } = imageconfig; if (formats.length === 0) { formats.push(defaultFormat); } - let type = accept.types(formats) - console.log('Accepted types:', type) - type ||= defaultFormat; + let type = accept.types(formats) || defaultFormat; if(Array.isArray(type)) { type = type[0]; } - console.log('Resolved type to:', type) const source = searchParams.get("url"); const width = searchParams.get("w"); @@ -48,7 +44,6 @@ const handler = async (req: Request, context: Context) => { modifiers.push(`f_${type}`); } const target = `/_ipx/${modifiers.join(",")}/${encodeURIComponent(source)}`; - console.log('Loading image from', target) return context.rewrite( target, );