Skip to content

feat: support custom response headers on images served via IPX #1515

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 12, 2022
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions demos/default/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@
"engines": {
"node": ">=12.0.0"
}
}
}
3 changes: 3 additions & 0 deletions packages/runtime/src/helpers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ export const setupImageFunction = async ({
netlifyConfig,
basePath,
remotePatterns,
responseHeaders,
}: {
constants: NetlifyPluginConstants
netlifyConfig: NetlifyConfig
basePath: string
imageconfig: Partial<ImageConfigComplete>
remotePatterns: RemotePattern[]
responseHeaders?: Record<string, string>
}): Promise<void> => {
const functionsPath = INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC
const functionName = `${IMAGE_FUNCTION_NAME}.js`
Expand All @@ -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))

Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/src/helpers/utils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -185,3 +187,13 @@ export const isNextAuthInstalled = (): boolean => {
return false
}
}

export const getCustomImageResponseHeaders = (headers: Header[]): Record<string, string> | null => {
const customImageResponseHeaders = headers.find((header) => header.for?.startsWith('/_next/image/'))

if (customImageResponseHeaders) {
return customImageResponseHeaders?.values as Record<string, string>
}
return null
}
/* eslint-enable max-lines */
3 changes: 2 additions & 1 deletion packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,6 +129,7 @@ const plugin: NetlifyPlugin = {
netlifyConfig,
basePath,
remotePatterns: experimentalRemotePatterns,
responseHeaders: getCustomImageResponseHeaders(netlifyConfig.headers),
})

await generateRedirects({
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime/src/templates/ipx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
33 changes: 33 additions & 0 deletions test/helpers/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Chance from 'chance'
import { getCustomImageResponseHeaders } from '../../plugin/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,
})
})
})
23 changes: 21 additions & 2 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
})

Expand Down