diff --git a/README.md b/README.md index dfd0d43a9c..eb0b86dbed 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,19 @@ In order to deliver the correct format to a visitor's browser, this uses a Netli 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`. +## Returning custom response headers on images handled by `ipx` + +Should you wish to return custom response headers on images handled by the [`netlify-ipx`](https://github.com/netlify/netlify-ipx) package, you can add them within your project's `netlify.toml` by targeting the `/_next/image/*` route: + +``` +[[headers]] + for = "/_next/image/*" + + [headers.values] + Strict-Transport-Security = "max-age=31536000" + X-Test = 'foobar' +``` + ## Next.js Middleware on Netlify Next.js Middleware works out of the box on Netlify, but check out the diff --git a/demos/default/netlify.toml b/demos/default/netlify.toml index 62dc346dc1..606fb255f8 100644 --- a/demos/default/netlify.toml +++ b/demos/default/netlify.toml @@ -11,6 +11,13 @@ CYPRESS_CACHE_FOLDER = "../node_modules/.CypressBinary" TERM = "xterm" NODE_VERSION = "16.15.1" +[[headers]] + for = "/_next/image/*" + + [headers.values] + Strict-Transport-Security = "max-age=31536000" + X-Test = 'foobar' + [dev] framework = "#static" diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 2e8648661c..32076f9c33 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -74,4 +74,4 @@ "engines": { "node": ">=12.0.0" } -} \ No newline at end of file +} diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index a4696d8257..f0d407510f 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -60,12 +60,14 @@ export const setupImageFunction = async ({ netlifyConfig, basePath, remotePatterns, + responseHeaders, }: { constants: NetlifyPluginConstants netlifyConfig: NetlifyConfig basePath: string imageconfig: Partial remotePatterns: RemotePattern[] + responseHeaders?: Record }): Promise => { const functionsPath = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC const functionName = `${IMAGE_FUNCTION_NAME}.js` @@ -76,6 +78,7 @@ export const setupImageFunction = async ({ ...imageconfig, basePath: [basePath, IMAGE_FUNCTION_NAME].join('/'), remotePatterns, + responseHeaders, }) await copyFile(join(__dirname, '..', '..', 'lib', 'templates', 'ipx.js'), join(functionDirectory, functionName)) diff --git a/packages/runtime/src/helpers/utils.ts b/packages/runtime/src/helpers/utils.ts index 19e549f7d2..4d83cf45bd 100644 --- a/packages/runtime/src/helpers/utils.ts +++ b/packages/runtime/src/helpers/utils.ts @@ -1,4 +1,6 @@ +/* eslint-disable max-lines */ import type { NetlifyConfig } from '@netlify/build' +import type { Header } from '@netlify/build/types/config/netlify_config' import globby from 'globby' import { join } from 'pathe' @@ -185,3 +187,13 @@ export const isNextAuthInstalled = (): boolean => { return false } } + +export const getCustomImageResponseHeaders = (headers: Header[]): Record | null => { + const customImageResponseHeaders = headers.find((header) => header.for?.startsWith('/_next/image/')) + + if (customImageResponseHeaders) { + return customImageResponseHeaders?.values as Record + } + return null +} +/* eslint-enable max-lines */ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6b25aade40..ad12b0753a 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -19,7 +19,7 @@ import { updateConfig, writeEdgeFunctions, loadMiddlewareManifest } from './help import { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } from './helpers/files' import { generateFunctions, setupImageFunction, generatePagesResolver } from './helpers/functions' import { generateRedirects, generateStaticRedirects } from './helpers/redirects' -import { shouldSkip, isNextAuthInstalled } from './helpers/utils' +import { shouldSkip, isNextAuthInstalled, getCustomImageResponseHeaders } from './helpers/utils' import { verifyNetlifyBuildVersion, checkNextSiteHasBuilt, @@ -129,6 +129,7 @@ const plugin: NetlifyPlugin = { netlifyConfig, basePath, remotePatterns: experimentalRemotePatterns, + responseHeaders: getCustomImageResponseHeaders(netlifyConfig.headers), }) await generateRedirects({ diff --git a/packages/runtime/src/templates/ipx.ts b/packages/runtime/src/templates/ipx.ts index e509d3eb8f..fe385290c1 100644 --- a/packages/runtime/src/templates/ipx.ts +++ b/packages/runtime/src/templates/ipx.ts @@ -3,11 +3,12 @@ import { Handler } from '@netlify/functions' import { createIPXHandler } from '@netlify/ipx' // @ts-ignore Injected at build time -import { basePath, domains, remotePatterns } from './imageconfig.json' +import { basePath, domains, remotePatterns, responseHeaders } from './imageconfig.json' export const handler: Handler = createIPXHandler({ basePath, domains, remotePatterns, + responseHeaders, }) as Handler /* eslint-enable n/no-missing-import, import/no-unresolved, @typescript-eslint/ban-ts-comment */ diff --git a/test/helpers/utils.spec.ts b/test/helpers/utils.spec.ts new file mode 100644 index 0000000000..940e1d27ed --- /dev/null +++ b/test/helpers/utils.spec.ts @@ -0,0 +1,33 @@ +import Chance from 'chance' +import { getCustomImageResponseHeaders } from '../../packages/runtime/src/helpers/utils' + +const chance = new Chance() + +describe('getCustomImageResponseHeaders', () => { + it('returns null when no custom image response headers are found', () => { + const mockHeaders = [{ + for: '/test', + values: { + 'X-Foo': chance.string() + } + }] + + expect(getCustomImageResponseHeaders(mockHeaders)).toBe(null) + }) + + it('returns header values when custom image response headers are found', () => { + const mockFooValue = chance.string() + + const mockHeaders = [{ + for: '/_next/image/', + values: { + 'X-Foo': mockFooValue + } + }] + + const result = getCustomImageResponseHeaders(mockHeaders) + expect(result).toStrictEqual({ + 'X-Foo': mockFooValue, + }) + }) +}) diff --git a/test/index.js b/test/index.js index 30ff93d84e..c150b8b66b 100644 --- a/test/index.js +++ b/test/index.js @@ -530,13 +530,32 @@ describe('onBuild()', () => { expect(await plugin.onBuild(defaultArgs)).toBeUndefined() }) - test('generates imageconfig file with entries for domains and remotePatterns', async () => { + test('generates imageconfig file with entries for domains, remotePatterns, and custom response headers', async () => { await moveNextDist() - await plugin.onBuild(defaultArgs) + const mockHeaderValue = chance.string() + + const updatedArgs = { + ...defaultArgs, + netlifyConfig: { + ...defaultArgs.netlifyConfig, + headers: [{ + for: '/_next/image/', + values: { + 'X-Foo': mockHeaderValue + } + }] + } + } + await plugin.onBuild(updatedArgs) + const imageConfigPath = path.join(constants.INTERNAL_FUNCTIONS_SRC, IMAGE_FUNCTION_NAME, 'imageconfig.json') const imageConfigJson = await readJson(imageConfigPath) + expect(imageConfigJson.domains.length).toBe(1) expect(imageConfigJson.remotePatterns.length).toBe(1) + expect(imageConfigJson.responseHeaders).toStrictEqual({ + 'X-Foo': mockHeaderValue + }) }) })