Skip to content

Commit 40cb8a9

Browse files
ericapisaninickytonlineascorbic
authored
feat: support custom response headers on images served via IPX (#1515)
* feat: support custom response headers in image handler Depends on netlify/netlify-ipx#51 * test: install ipx package in dev includes the changes needed to support custom response headers * chore(deps): upgrade ipx package this version contains changs for supporting custom response headers * test: add test coverage * style: run lint * docs: add info on custom response headers * fix: address CR comments * Update test/helpers/utils.spec.ts Co-authored-by: Matt Kane <[email protected]> Co-authored-by: Nick Taylor <[email protected]> Co-authored-by: Matt Kane <[email protected]>
1 parent 4e7e343 commit 40cb8a9

File tree

9 files changed

+94
-5
lines changed

9 files changed

+94
-5
lines changed

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ In order to deliver the correct format to a visitor's browser, this uses a Netli
5353
site may not support Edge Functions, in which case it will instead fall back to delivering the original file format. You
5454
may also manually disable the Edge Function by setting the environment variable `NEXT_DISABLE_EDGE_IMAGES` to `true`.
5555

56+
## Returning custom response headers on images handled by `ipx`
57+
58+
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:
59+
60+
```
61+
[[headers]]
62+
for = "/_next/image/*"
63+
64+
[headers.values]
65+
Strict-Transport-Security = "max-age=31536000"
66+
X-Test = 'foobar'
67+
```
68+
5669
## Next.js Middleware on Netlify
5770

5871
Next.js Middleware works out of the box on Netlify, but check out the

demos/default/netlify.toml

+7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ CYPRESS_CACHE_FOLDER = "../node_modules/.CypressBinary"
1111
TERM = "xterm"
1212
NODE_VERSION = "16.15.1"
1313

14+
[[headers]]
15+
for = "/_next/image/*"
16+
17+
[headers.values]
18+
Strict-Transport-Security = "max-age=31536000"
19+
X-Test = 'foobar'
20+
1421
[dev]
1522
framework = "#static"
1623

packages/runtime/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,4 @@
7474
"engines": {
7575
"node": ">=12.0.0"
7676
}
77-
}
77+
}

packages/runtime/src/helpers/functions.ts

+3
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,14 @@ export const setupImageFunction = async ({
6060
netlifyConfig,
6161
basePath,
6262
remotePatterns,
63+
responseHeaders,
6364
}: {
6465
constants: NetlifyPluginConstants
6566
netlifyConfig: NetlifyConfig
6667
basePath: string
6768
imageconfig: Partial<ImageConfigComplete>
6869
remotePatterns: RemotePattern[]
70+
responseHeaders?: Record<string, string>
6971
}): Promise<void> => {
7072
const functionsPath = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC
7173
const functionName = `${IMAGE_FUNCTION_NAME}.js`
@@ -76,6 +78,7 @@ export const setupImageFunction = async ({
7678
...imageconfig,
7779
basePath: [basePath, IMAGE_FUNCTION_NAME].join('/'),
7880
remotePatterns,
81+
responseHeaders,
7982
})
8083
await copyFile(join(__dirname, '..', '..', 'lib', 'templates', 'ipx.js'), join(functionDirectory, functionName))
8184

packages/runtime/src/helpers/utils.ts

+12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
/* eslint-disable max-lines */
12
import type { NetlifyConfig } from '@netlify/build'
3+
import type { Header } from '@netlify/build/types/config/netlify_config'
24
import globby from 'globby'
35
import { join } from 'pathe'
46

@@ -185,3 +187,13 @@ export const isNextAuthInstalled = (): boolean => {
185187
return false
186188
}
187189
}
190+
191+
export const getCustomImageResponseHeaders = (headers: Header[]): Record<string, string> | null => {
192+
const customImageResponseHeaders = headers.find((header) => header.for?.startsWith('/_next/image/'))
193+
194+
if (customImageResponseHeaders) {
195+
return customImageResponseHeaders?.values as Record<string, string>
196+
}
197+
return null
198+
}
199+
/* eslint-enable max-lines */

packages/runtime/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { updateConfig, writeEdgeFunctions, loadMiddlewareManifest } from './help
1919
import { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } from './helpers/files'
2020
import { generateFunctions, setupImageFunction, generatePagesResolver } from './helpers/functions'
2121
import { generateRedirects, generateStaticRedirects } from './helpers/redirects'
22-
import { shouldSkip, isNextAuthInstalled } from './helpers/utils'
22+
import { shouldSkip, isNextAuthInstalled, getCustomImageResponseHeaders } from './helpers/utils'
2323
import {
2424
verifyNetlifyBuildVersion,
2525
checkNextSiteHasBuilt,
@@ -129,6 +129,7 @@ const plugin: NetlifyPlugin = {
129129
netlifyConfig,
130130
basePath,
131131
remotePatterns: experimentalRemotePatterns,
132+
responseHeaders: getCustomImageResponseHeaders(netlifyConfig.headers),
132133
})
133134

134135
await generateRedirects({

packages/runtime/src/templates/ipx.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { Handler } from '@netlify/functions'
33
import { createIPXHandler } from '@netlify/ipx'
44

55
// @ts-ignore Injected at build time
6-
import { basePath, domains, remotePatterns } from './imageconfig.json'
6+
import { basePath, domains, remotePatterns, responseHeaders } from './imageconfig.json'
77

88
export const handler: Handler = createIPXHandler({
99
basePath,
1010
domains,
1111
remotePatterns,
12+
responseHeaders,
1213
}) as Handler
1314
/* eslint-enable n/no-missing-import, import/no-unresolved, @typescript-eslint/ban-ts-comment */

test/helpers/utils.spec.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Chance from 'chance'
2+
import { getCustomImageResponseHeaders } from '../../packages/runtime/src/helpers/utils'
3+
4+
const chance = new Chance()
5+
6+
describe('getCustomImageResponseHeaders', () => {
7+
it('returns null when no custom image response headers are found', () => {
8+
const mockHeaders = [{
9+
for: '/test',
10+
values: {
11+
'X-Foo': chance.string()
12+
}
13+
}]
14+
15+
expect(getCustomImageResponseHeaders(mockHeaders)).toBe(null)
16+
})
17+
18+
it('returns header values when custom image response headers are found', () => {
19+
const mockFooValue = chance.string()
20+
21+
const mockHeaders = [{
22+
for: '/_next/image/',
23+
values: {
24+
'X-Foo': mockFooValue
25+
}
26+
}]
27+
28+
const result = getCustomImageResponseHeaders(mockHeaders)
29+
expect(result).toStrictEqual({
30+
'X-Foo': mockFooValue,
31+
})
32+
})
33+
})

test/index.js

+21-2
Original file line numberDiff line numberDiff line change
@@ -530,13 +530,32 @@ describe('onBuild()', () => {
530530
expect(await plugin.onBuild(defaultArgs)).toBeUndefined()
531531
})
532532

533-
test('generates imageconfig file with entries for domains and remotePatterns', async () => {
533+
test('generates imageconfig file with entries for domains, remotePatterns, and custom response headers', async () => {
534534
await moveNextDist()
535-
await plugin.onBuild(defaultArgs)
535+
const mockHeaderValue = chance.string()
536+
537+
const updatedArgs = {
538+
...defaultArgs,
539+
netlifyConfig: {
540+
...defaultArgs.netlifyConfig,
541+
headers: [{
542+
for: '/_next/image/',
543+
values: {
544+
'X-Foo': mockHeaderValue
545+
}
546+
}]
547+
}
548+
}
549+
await plugin.onBuild(updatedArgs)
550+
536551
const imageConfigPath = path.join(constants.INTERNAL_FUNCTIONS_SRC, IMAGE_FUNCTION_NAME, 'imageconfig.json')
537552
const imageConfigJson = await readJson(imageConfigPath)
553+
538554
expect(imageConfigJson.domains.length).toBe(1)
539555
expect(imageConfigJson.remotePatterns.length).toBe(1)
556+
expect(imageConfigJson.responseHeaders).toStrictEqual({
557+
'X-Foo': mockHeaderValue
558+
})
540559
})
541560
})
542561

0 commit comments

Comments
 (0)