From 057c0364e44e508ae25f2f38bb8abe37ac637ce4 Mon Sep 17 00:00:00 2001 From: Chris Meagher Date: Wed, 15 Nov 2023 20:23:49 +0000 Subject: [PATCH 1/5] Remove `Accept-Encoding` header from requests to origin from CDN --- .env.production | 2 +- infra/cdn/cdn.bicep | 79 +++++++++++++++++++++++++++++++-------------- infra/main.bicep | 7 ++-- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/.env.production b/.env.production index 8652815..948a485 100644 --- a/.env.production +++ b/.env.production @@ -1,3 +1,3 @@ # Set defaults for the production build (i.e. `next dev`) here -NEXT_COMPRESS="false" +NEXT_COMPRESS="true" NODE_ENV="production" diff --git a/infra/cdn/cdn.bicep b/infra/cdn/cdn.bicep index 43bce1a..ef3450f 100644 --- a/infra/cdn/cdn.bicep +++ b/infra/cdn/cdn.bicep @@ -3,6 +3,7 @@ param endpointName string param location string = resourceGroup().location param tags object = {} param originHostName string +param buildId string var originUri = 'https://${originHostName}' @@ -57,15 +58,15 @@ resource endpoint 'Microsoft.Cdn/profiles/endpoints@2022-11-01-preview' = { { name: 'RequestHeader' parameters: { - operator: length(originUri) > 64 ? 'BeginsWith' : 'Equal' - selector: 'Origin' - negateCondition: false - // A match value longer than 64 characters will cause an error - matchValues: [ - length(originUri) > 64 ? take(originUri, 64) : originUri - ] - transforms: [] - typeName: 'DeliveryRuleRequestHeaderConditionParameters' + operator: length(originUri) > 64 ? 'BeginsWith' : 'Equal' + selector: 'Origin' + negateCondition: false + // A match value longer than 64 characters will cause an error + matchValues: [ + length(originUri) > 64 ? take(originUri, 64) : originUri + ] + transforms: [] + typeName: 'DeliveryRuleRequestHeaderConditionParameters' } } ] @@ -73,10 +74,10 @@ resource endpoint 'Microsoft.Cdn/profiles/endpoints@2022-11-01-preview' = { { name: 'ModifyResponseHeader' parameters: { - headerAction: 'Overwrite' - headerName: 'Access-Control-Allow-Origin' - value: originUri - typeName: 'DeliveryRuleHeaderActionParameters' + headerAction: 'Overwrite' + headerName: 'Access-Control-Allow-Origin' + value: originUri + typeName: 'DeliveryRuleHeaderActionParameters' } } ] @@ -88,13 +89,13 @@ resource endpoint 'Microsoft.Cdn/profiles/endpoints@2022-11-01-preview' = { { name: 'UrlPath' parameters: { - operator: 'BeginsWith' - negateCondition: false - matchValues: [ - '_next/' - ] - transforms: [] - typeName: 'DeliveryRuleUrlPathMatchConditionParameters' + operator: 'BeginsWith' + negateCondition: false + matchValues: [ + '_next/' + ] + transforms: [] + typeName: 'DeliveryRuleUrlPathMatchConditionParameters' } } ] @@ -102,10 +103,40 @@ resource endpoint 'Microsoft.Cdn/profiles/endpoints@2022-11-01-preview' = { { name: 'ModifyResponseHeader' parameters: { - headerAction: 'Overwrite' - headerName: 'Access-Control-Allow-Origin' - value: originUri - typeName: 'DeliveryRuleHeaderActionParameters' + headerAction: 'Overwrite' + headerName: 'Access-Control-Allow-Origin' + value: originUri + typeName: 'DeliveryRuleHeaderActionParameters' + } + } + ] + } + { + name: 'NoCompressAtOrigin' + order: 3 + conditions: [ + { + name: 'UrlPath' + parameters: { + operator: 'BeginsWith' + negateCondition: false + matchValues: [ + '_next/' + '${buildId}/' + ] + transforms: [] + typeName: 'DeliveryRuleUrlPathMatchConditionParameters' + } + } + ] + actions: [ + { + name: 'ModifyRequestHeader' + parameters: { + headerAction: 'Delete' + headerName: 'Accept-Encoding' + value: null + typeName: 'DeliveryRuleHeaderActionParameters' } } ] diff --git a/infra/main.bicep b/infra/main.bicep index 498bc89..be89c6b 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -138,6 +138,8 @@ var webAppServiceHostName = !empty(webAppServiceCustomDomainName) ? webAppServic var webAppServiceUri = 'https://${webAppServiceHostName}' +var buildId = uniqueString(resourceGroup.id, deployment().name) + module webAppServiceCdn './cdn/cdn.bicep' = { name: '${webAppServiceName}-cdn' scope: resourceGroup @@ -147,11 +149,10 @@ module webAppServiceCdn './cdn/cdn.bicep' = { location: location tags: tags originHostName: webAppServiceHostName + buildId: buildId } } -var buildId = uniqueString(resourceGroup.id, deployment().name) - module webAppServiceContainerApp './web-app.bicep' = { name: '${webAppServiceName}-container-app' scope: resourceGroup @@ -176,7 +177,7 @@ module webAppServiceContainerApp './web-app.bicep' = { } { name: 'NEXT_COMPRESS' - value: stringOrDefault(envVars.NEXT_COMPRESS, 'false') + value: stringOrDefault(envVars.NEXT_COMPRESS, 'true') } { name: 'NEXT_PUBLIC_APP_ENV' From 1cfd5bd5230cac9050905d73262136e6e25a6408 Mon Sep 17 00:00:00 2001 From: Chris Meagher Date: Wed, 15 Nov 2023 20:38:20 +0000 Subject: [PATCH 2/5] Add `Cache-Control` header in response to requests for immutable resources --- next.config.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/next.config.js b/next.config.js index fb6871a..0c11b8e 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,7 @@ const buildId = process.env.NEXT_PUBLIC_BUILD_ID || null const customDomainName = process.env.SERVICE_WEB_CUSTOM_DOMAIN_NAME || '' const remotePatterns = [] +const headers = [] const rewrites = {} const redirects = [] @@ -25,6 +26,17 @@ if (buildId) { destination: '/:path*' } ] + + // Also add a cache control header as these resources are immutable for each build + headers.push({ + source: `/${buildId}/:path*`, + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable' + } + ] + }) } if (customDomainName) { @@ -62,6 +74,9 @@ const nextConfig = { images: { remotePatterns }, + async headers() { + return headers + }, async rewrites() { return rewrites }, From 95a957ef48f60a55692cadc0e5d9fc55cb6f8b28 Mon Sep 17 00:00:00 2001 From: Chris Meagher Date: Wed, 15 Nov 2023 21:22:47 +0000 Subject: [PATCH 3/5] Add preconnect for CDN resources --- src/app/layout.tsx | 2 ++ src/app/preload-resources.tsx | 17 +++++++++++++++++ src/lib/url.ts | 16 ++++++++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/app/preload-resources.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5d33957..d432d44 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import './globals.css' import type { Metadata } from 'next' import { Inter } from 'next/font/google' +import { PreloadResources } from './preload-resources' import { AppInsightsProvider } from '@/components/instrumentation/AppInsightsProvider' const inter = Inter({ subsets: ['latin'] }) @@ -17,6 +18,7 @@ export default function RootLayout({ }) { return ( + {children} diff --git a/src/app/preload-resources.tsx b/src/app/preload-resources.tsx new file mode 100644 index 0000000..c088752 --- /dev/null +++ b/src/app/preload-resources.tsx @@ -0,0 +1,17 @@ +'use client' + +import ReactDOM from 'react-dom' +import { getCdnUrl } from '@/lib/url' + +function PreloadResources() { + const cdnUrl = getCdnUrl('', false) + + if (cdnUrl.length > 0) { + // @ts-ignore + ReactDOM.preconnect(cdnUrl) + } + + return null +} + +export { PreloadResources } diff --git a/src/lib/url.ts b/src/lib/url.ts index 81cfd67..b13d3ae 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -32,12 +32,20 @@ const getAbsoluteUrl = (path?: string) => { return joinUrlSegments([baseUrl, path]) } -const getCdnUrl = (path: string) => { - if (!baseCdnUrl || !buildId) { - return path +const getCdnUrl = (path?: string, includeFingerprint = true) => { + if (!baseCdnUrl) { + return path ?? '' } - return joinUrlSegments([baseCdnUrl, buildId, path]) + if (!path) { + return baseCdnUrl + } + + if (includeFingerprint && buildId) { + return joinUrlSegments([baseCdnUrl, buildId, path]) + } + + return joinUrlSegments([baseCdnUrl, path]) } export { getAbsoluteUrl, getCdnUrl } From 6c7161ad6819871d5a4d4a34f84f17c00d78e063 Mon Sep 17 00:00:00 2001 From: Chris Meagher Date: Wed, 15 Nov 2023 21:23:27 +0000 Subject: [PATCH 4/5] Update `prettier` --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc9e7a0..961db13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "pino-pretty": "^10.2.0", - "prettier": "^3.0.3", + "prettier": "^3.1.0", "typescript": "^5" } }, diff --git a/package.json b/package.json index 7ba1806..a5f9325 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "pino-pretty": "^10.2.0", - "prettier": "^3.0.3", + "prettier": "^3.1.0", "typescript": "^5" } } From d47a4a74cee3151918f8b11b2de39036ee045d78 Mon Sep 17 00:00:00 2001 From: Chris Meagher Date: Wed, 15 Nov 2023 22:21:53 +0000 Subject: [PATCH 5/5] Update CDN section in README --- README.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index cd01aa5..af5c246 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # nextjs-aca -An `azd` ([Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/overview)) template for getting a [Next.js](https://nextjs.org/) 13 app running on Azure Container Apps with CDN and Application Insights. +An `azd` ([Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/overview)) template for getting a [Next.js](https://nextjs.org/) app running on Azure Container Apps with CDN and Application Insights. -The Next.js 13 app included with the template has been generated with [`create-next-app`](https://nextjs.org/docs/getting-started/installation#automatic-installation) and has some additional code and components specific to this template that provide: +The Next.js app included with the template has been generated with [`create-next-app`](https://nextjs.org/docs/getting-started/installation#automatic-installation) and has some additional code and components specific to this template that provide: * [Server-side and client-side instrumentation and logging via App Insights](#application-insights) * [Serving of Next.js static assets through an Azure CDN (with cache busting)](#azure-cdn) @@ -39,7 +39,7 @@ azd deploy > The output from the `azd deploy` command includes a link to the Resource Group in your Azure Subscription where you can see the provisioned infrastructure resources. A link to the Next.js app running in Azure is also included so you can quickly navigate to your Next.js app that is now hosted in Azure. -🚀 You now have a Next.js 13 app running in Container Apps in Azure with a CDN for fast delivery of static files and Application Insights attached for monitoring! +🚀 You now have a Next.js app running in Container Apps in Azure with a CDN for fast delivery of static files and Application Insights attached for monitoring! 💥 When you're done testing you can run `azd down` in the terminal and that will delete the Resource Group and all of the resources in it. @@ -47,8 +47,8 @@ azd deploy If you do not have access to or do not want to work in Codespaces or a Dev Container you can of course work locally, but you will need to ensure you have the following pre-requisites installed: -* [Node.js](https://nodejs.org/) v18.x - * Later versions should be fine also, but v18.x is the target version set in the template +* [Node.js](https://nodejs.org/) v18.17 or later + * This Node.js version is a [minimum requirement of Next.js](https://nextjs.org/docs/getting-started/installation) * Use of [`nvm`](https://github.com/nvm-sh/nvm) or [`fnm`](https://github.com/Schniz/fnm) is recommended * [PowerShell](https://github.com/PowerShell/PowerShell) * [Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) @@ -66,7 +66,7 @@ If you do not have access to or do not want to work in Codespaces or a Dev Conta You should develop your Next.js app as [you normally would](https://nextjs.org/docs/app/building-your-application) with a couple of (hopefully minor) concessions: -* As and when environment variables need to be added to a `.env.local` file that the the `.env.local.template` file is updated to include a matching empty or example entry +* As and when environment variables need to be added to a `.env.local` file that the the `.env.local.template` file is updated to include a matching entry with an empty or default value * This is so that the `azd provision` and `azd deploy` hooks have context of all of the environment variables required by your app at build and at runtime * See the [Environment variables](#environment-variables) section for a fuller description of why this is needed * To get the most out of the CDN and App Insights resources used by this template you should keep (or copy across) the relevant code and configuration assets related to these resources @@ -106,7 +106,7 @@ Then when you're finished with the deployment run: azd down ``` -> `azd` has an `azd up` command, which the [docs describe as](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create#update-azureyaml) *"You can run `azd up` to perform both `azd provision` and `azd deploy` in a single step"*. Running `azd up` actually seems to be the equivalent of `azd package` -> `azd provision` -> `azd deploy` though, which does not work for this template because outputs from the `azd provision` step such as the app's URL and the CDN endpoint URL are required by `next build`, which is run inside the `Dockerfile` during `azd package`. So unless the behaviour of `azd up` can be changed in future you will need to continue to run `azd provision` -> `azd deploy`. +> `azd` has an `azd up` command, which the [docs describe as](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create#update-azureyaml) *"You can run `azd up` to perform both `azd provision` and `azd deploy` in a single step"*. Running `azd up` is actually the equivalent of `azd package` -> `azd provision` -> `azd deploy` though, which does not work for this template because outputs from the `azd provision` step such as the app's URL and the CDN endpoint URL are required by `next build`, which is run inside the `Dockerfile` during `azd package`. So unless the behaviour of `azd up` can be changed in future you will need to continue to run `azd provision` -> `azd deploy`. ## Deploying to Azure with `azd` in a CI/CD pipeline @@ -145,23 +145,29 @@ Client-side logging can be performed by using the `useAppInsightsContext` hook f ## Azure CDN -An [Azure CDN endpoint](https://learn.microsoft.com/en-us/azure/cdn/cdn-create-endpoint-how-to) is used to serve Next.js's static assets via the [`assetPrefix`](https://nextjs.org/docs/app/api-reference/next-config-js/assetPrefix) configuration option. +An [Azure CDN endpoint](https://learn.microsoft.com/en-us/azure/cdn/cdn-create-endpoint-how-to) is deployed and configured to work in "origin pull" mode, which means the first request made for a resource to the CDN will proxy through to the Container App (the origin) and the response will be cached on the CDN for subsequent requests. -The static assets from the Next.js build output and [`public` folder](https://nextjs.org/docs/getting-started/installation#the-public-folder-optional) are included in the Docker image that is created during the `azd deploy` step and deployed to your Container App. +For this to work the static assets from the Next.js build output and [`public` folder](https://nextjs.org/docs/getting-started/installation#the-public-folder-optional) are included in the Docker image that is created during the `azd deploy` step and deployed to your Container App. -The CDN endpoint is configured to work in "origin pull" mode, meaning the first request made to the CDN for a static asset will proxy through to the Container App, but the response will be cached on the CDN for subsequent requests. +To configure the CDN to be used for requests to Next.js's static assets the [`assetPrefix`](https://nextjs.org/docs/app/api-reference/next-config-js/assetPrefix) configuration option is set in `next.config.js`. -💡 The template will also by default set Next.js's [`compress`](https://nextjs.org/docs/app/api-reference/next-config-js/compress) configuration option to `false` because the CDN will provide compression; and the [`remotePatterns`](https://nextjs.org/docs/app/api-reference/components/image#remotepatterns) configuration option is used to allow CDN URLs to be used by the Next.js's `` component. +To use the CDN for requests to other resources (such as those in the `public` folder) you can `import { getCdnUrl } from '@/lib/url'` and use the `getCdnUrl` function to generate a URL that will proxy the request through the CDN. -The template also includes a function that allows you to generate a CDN URL from a relative path if you want to direct certain requests through the CDN, for example images in the `public` folder. To use the function you can `import { getCdnUrl } from '@/lib/url'`. +The template also includes a function that allows you to generate an absolute URL from a relative path if you require it (i.e. direct to the origin without proxying through the CDN). To use the function you can `import { getAbsoluteUrl } from '@/lib/url'`. -💡 The `getCdnUrl` function uses a [`buildId`](https://nextjs.org/docs/app/api-reference/next-config-js/generateBuildId) when constructing the URL so that the URLs will change when you deploy a new version of your app, which is provided as a "cache busting" strategy. +> For an example of how `getCdnUrl` can be used see `page.tsx`. An example of `getAbsoluteUrl` can be seen in `robots.ts`. -The template also includes a function that allows you to generate an absolute URL from a relative path if you require it. To use the function you can `import { getAbsoluteUrl } from '@/lib/url'`. +As well as `assetPrefix` there are some other related configuration options set in `next.config.js`: + +* The [`compress`](https://nextjs.org/docs/app/api-reference/next-config-js/compress) option is set to `true` by default because although the CDN will provide compression for static assets pulled from the origin the CDN doesn't cover dynamic assets +* The [`remotePatterns`](https://nextjs.org/docs/app/api-reference/components/image#remotepatterns) option is set to allow CDN URLs to be used by the Next.js's `` component +* A far-future `Cache-Control` header is set via the [`headers`](https://nextjs.org/docs/app/api-reference/next-config-js/headers) option for requests that include the `buildId` in the URL (the `getCdnUrl` function adds this by default as a cache-busting strategy) + +💡 The template also adds a [`preconnect`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preconnect) for the CDN in `layout.tsx`. > The features described above require the presence of the environment variables `NEXT_PUBLIC_CDN_URL`, `NEXT_PUBLIC_CDN_HOSTNAME`, `NEXT_COMPRESS`, `NEXT_PUBLIC_BUILD_ID` and `NEXT_PUBLIC_BASE_URL`. These are all provided to the app automatically with the exception of `NEXT_COMPRESS`, which is provided by `.env.production`. > -> If these environment variables are not provided, for example when running in your development environment outside of `azd provision`, the `assetPrefix`, `remotePatterns` and `buildId` will not be set, and the `getCdnUrl` function will return the relative path that was provided as input. +> If these environment variables are not provided, for example when running in your development environment outside of `azd provision`, the `assetPrefix`, `remotePatterns` and `headers` will not be set, the `getCdnUrl` and `getAbsoluteUrl` functions will return the relative path that was provided as input to the function, and the `preconnect` will not be added. ## Checking the current environment at runtime