Skip to content

Commit

Permalink
Merge pull request #10 from CMeeg/feature/fix-cdn-timeouts
Browse files Browse the repository at this point in the history
  • Loading branch information
CMeeg authored Nov 15, 2023
2 parents 61e413e + d47a4a7 commit 1b5a2cd
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 49 deletions.
2 changes: 1 addition & 1 deletion .env.production
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Set defaults for the production build (i.e. `next dev`) here
NEXT_COMPRESS="false"
NEXT_COMPRESS="true"
NODE_ENV="production"
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -39,16 +39,16 @@ 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.

## Setting up locally

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

Expand Down Expand Up @@ -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 `<Image>` 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 `<Image>` 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

Expand Down
79 changes: 55 additions & 24 deletions infra/cdn/cdn.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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}'

Expand Down Expand Up @@ -57,26 +58,26 @@ 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'
}
}
]
actions: [
{
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'
}
}
]
Expand All @@ -88,24 +89,54 @@ 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'
}
}
]
actions: [
{
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'
}
}
]
Expand Down
7 changes: 4 additions & 3 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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'
Expand Down
15 changes: 15 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand All @@ -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) {
Expand Down Expand Up @@ -62,6 +74,9 @@ const nextConfig = {
images: {
remotePatterns
},
async headers() {
return headers
},
async rewrites() {
return rewrites
},
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'] })
Expand All @@ -17,6 +18,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<PreloadResources />
<body className={inter.className}>
<AppInsightsProvider>{children}</AppInsightsProvider>
</body>
Expand Down
17 changes: 17 additions & 0 deletions src/app/preload-resources.tsx
Original file line number Diff line number Diff line change
@@ -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 }
16 changes: 12 additions & 4 deletions src/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

0 comments on commit 1b5a2cd

Please sign in to comment.