Skip to content

Commit b2b006f

Browse files
taty2010pieh
andauthored
Image CDN: Remote images (#242)
* remote images setup * fixed remotePattern issue * update tests to use imageManifest * added new tests for pattern and domain * fix test * update domain + tests * testing out sus * revert tests and plugin-context changes * test: use more tricky remote pattern * test: use even more tricky remote pattern * compile regex from remote patterns, create domain regex with https? start * fix: handle fallbacks for optional settings * fix: remove lookaheads from image regexes * test: add another remote patterns case, re-organize fixture a bit --------- Co-authored-by: Michal Piechowiak <[email protected]>
1 parent c4c4214 commit b2b006f

File tree

10 files changed

+248
-35
lines changed

10 files changed

+248
-35
lines changed

package-lock.json

+16-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+5-2
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,14 @@
5151
"@netlify/serverless-functions-api": "^1.10.1",
5252
"@netlify/zip-it-and-ship-it": "^9.29.2",
5353
"@opentelemetry/api": "^1.7.0",
54-
"@opentelemetry/sdk-node": "^0.48.0",
5554
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
5655
"@opentelemetry/resources": "^1.21.0",
57-
"@opentelemetry/semantic-conventions": "^1.21.0",
56+
"@opentelemetry/sdk-node": "^0.48.0",
5857
"@opentelemetry/sdk-trace-node": "^1.21.0",
58+
"@opentelemetry/semantic-conventions": "^1.21.0",
5959
"@playwright/test": "^1.40.0",
6060
"@types/node": "^20.9.2",
61+
"@types/picomatch": "^2.3.3",
6162
"@types/uuid": "^9.0.6",
6263
"@vercel/nft": "^0.26.0",
6364
"cheerio": "^1.0.0-rc.12",
@@ -75,6 +76,8 @@
7576
"os": "^0.1.2",
7677
"outdent": "^0.8.0",
7778
"p-limit": "^4.0.0",
79+
"picomatch": "^3.0.1",
80+
"regexp-tree": "^0.1.27",
7881
"typescript": "^5.1.6",
7982
"unionfs": "^4.5.1",
8083
"uuid": "^9.0.1",

src/build/image-cdn.ts

+92-13
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,101 @@
1+
import type { RemotePattern } from 'next/dist/shared/lib/image-config.js'
2+
import { makeRe } from 'picomatch'
3+
import { transform } from 'regexp-tree'
4+
15
import { PluginContext } from './plugin-context.js'
26

7+
export function generateRegexFromPattern(pattern: string): string {
8+
const initialRegex = makeRe(pattern).source
9+
// resulting regex can contain lookaheads which currently cause problems with Netlify Image CDN remote patterns
10+
// so we strip them out
11+
// those regexes seems to be negative lookaheads for "dotfiles" / dots at the beginning of path segments
12+
// we actually are want to allow them and normally would pass dots: true option to `makeRe` function,
13+
// but this generally result in even more convoluted regular expression, so we just enable them via
14+
// stripping lookaheads
15+
16+
// Parse the regexp into an AST
17+
const re = transform(new RegExp(initialRegex), {
18+
Assertion(path) {
19+
// Remove the lookahead
20+
if (path.node.kind === 'Lookahead') {
21+
path.remove()
22+
}
23+
},
24+
})
25+
// Strip the leading and trailing slashes
26+
return re.toString().slice(1, -1)
27+
}
28+
329
/**
430
* Rewrite next/image to netlify image cdn
531
*/
632
export const setImageConfig = async (ctx: PluginContext): Promise<void> => {
733
const {
8-
images: { path: imageEndpointPath, loader: imageLoader },
9-
} = ctx.buildConfig
10-
11-
if (imageLoader === 'default') {
12-
ctx.netlifyConfig.redirects.push({
13-
from: imageEndpointPath,
14-
// w and q are too short to be used as params with id-length rule
15-
// but we are forced to do so because of the next/image loader decides on their names
16-
// eslint-disable-next-line id-length
17-
query: { url: ':url', w: ':width', q: ':quality' },
18-
to: '/.netlify/images?url=:url&w=:width&q=:quality',
19-
status: 200,
20-
})
34+
images: { domains, remotePatterns, path: imageEndpointPath, loader: imageLoader },
35+
} = await ctx.buildConfig
36+
if (imageLoader !== 'default') {
37+
return
38+
}
39+
40+
ctx.netlifyConfig.redirects.push({
41+
from: imageEndpointPath,
42+
// w and q are too short to be used as params with id-length rule
43+
// but we are forced to do so because of the next/image loader decides on their names
44+
// eslint-disable-next-line id-length
45+
query: { url: ':url', w: ':width', q: ':quality' },
46+
to: '/.netlify/images?url=:url&w=:width&q=:quality',
47+
status: 200,
48+
})
49+
50+
if (remotePatterns?.length !== 0 || domains?.length !== 0) {
51+
ctx.netlifyConfig.images ||= { remote_images: [] }
52+
ctx.netlifyConfig.images.remote_images ||= []
53+
54+
if (remotePatterns && remotePatterns.length !== 0) {
55+
for (const remotePattern of remotePatterns) {
56+
let { protocol, hostname, port, pathname }: RemotePattern = remotePattern
57+
58+
if (pathname) {
59+
pathname = pathname.startsWith('/') ? pathname : `/${pathname}`
60+
}
61+
62+
const combinedRemotePattern = `${protocol ?? 'http?(s)'}://${hostname}${
63+
port ? `:${port}` : ''
64+
}${pathname ?? '/**'}`
65+
66+
try {
67+
ctx.netlifyConfig.images.remote_images.push(
68+
generateRegexFromPattern(combinedRemotePattern),
69+
)
70+
} catch (error) {
71+
ctx.failBuild(
72+
`Failed to generate Image CDN remote image regex from Next.js remote pattern: ${JSON.stringify(
73+
{ remotePattern, combinedRemotePattern },
74+
null,
75+
2,
76+
)}`,
77+
error,
78+
)
79+
}
80+
}
81+
}
82+
83+
if (domains && domains.length !== 0) {
84+
for (const domain of domains) {
85+
const patternFromDomain = `http?(s)://${domain}/**`
86+
try {
87+
ctx.netlifyConfig.images.remote_images.push(generateRegexFromPattern(patternFromDomain))
88+
} catch (error) {
89+
ctx.failBuild(
90+
`Failed to generate Image CDN remote image regex from Next.js domain: ${JSON.stringify(
91+
{ domain, patternFromDomain },
92+
null,
93+
2,
94+
)}`,
95+
error,
96+
)
97+
}
98+
}
99+
}
21100
}
22101
}

tests/e2e/simple-app.test.ts

+73-14
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,6 @@ test('Redirects correctly', async ({ page, simpleNextApp }) => {
4242
await expect(page).toHaveURL(`https://www.netlify.com/`)
4343
})
4444

45-
test('next/image is using Netlify Image CDN', async ({ page, simpleNextApp }) => {
46-
const nextImageResponsePromise = page.waitForResponse('**/_next/image**')
47-
48-
await page.goto(`${simpleNextApp.url}/image`)
49-
50-
const nextImageResponse = await nextImageResponsePromise
51-
expect(nextImageResponse.request().url()).toContain('_next/image?url=%2Fsquirrel.jpg')
52-
// ensure next/image is using Image CDN
53-
// source image is jpg, but when requesting it through Image CDN avif will be returned
54-
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
55-
56-
await expectImageWasLoaded(page.locator('img'))
57-
})
58-
5945
const waitFor = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
6046

6147
// adaptation of https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts#L1716-L1755
@@ -106,3 +92,76 @@ test('streams stale responses', async ({ simpleNextApp }) => {
10692
).toBeLessThan(3000)
10793
}
10894
})
95+
96+
test.describe('next/image is using Netlify Image CDN', () => {
97+
test('Local images', async ({ page, simpleNextApp }) => {
98+
const nextImageResponsePromise = page.waitForResponse('**/_next/image**')
99+
100+
await page.goto(`${simpleNextApp.url}/image/local`)
101+
102+
const nextImageResponse = await nextImageResponsePromise
103+
expect(nextImageResponse.request().url()).toContain('_next/image?url=%2Fsquirrel.jpg')
104+
// ensure next/image is using Image CDN
105+
// source image is jpg, but when requesting it through Image CDN avif will be returned
106+
expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
107+
108+
await expectImageWasLoaded(page.locator('img'))
109+
})
110+
111+
test('Remote images: remote patterns #1 (protocol, hostname, pathname set)', async ({
112+
page,
113+
simpleNextApp,
114+
}) => {
115+
const nextImageResponsePromise = page.waitForResponse('**/_next/image**')
116+
117+
await page.goto(`${simpleNextApp.url}/image/remote-pattern-1`)
118+
119+
const nextImageResponse = await nextImageResponsePromise
120+
121+
expect(nextImageResponse.url()).toContain(
122+
`_next/image?url=${encodeURIComponent(
123+
'https://images.unsplash.com/photo-1574870111867-089730e5a72b',
124+
)}`,
125+
)
126+
127+
await expect(nextImageResponse?.status()).toBe(200)
128+
await expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
129+
})
130+
131+
test('Remote images: remote patterns #2 (just hostname starting with wildcard)', async ({
132+
page,
133+
simpleNextApp,
134+
}) => {
135+
const nextImageResponsePromise = page.waitForResponse('**/_next/image**')
136+
137+
await page.goto(`${simpleNextApp.url}/image/remote-pattern-2`)
138+
139+
const nextImageResponse = await nextImageResponsePromise
140+
141+
expect(nextImageResponse.url()).toContain(
142+
`_next/image?url=${encodeURIComponent(
143+
'https://cdn.pixabay.com/photo/2017/02/20/18/03/cat-2083492_1280.jpg',
144+
)}`,
145+
)
146+
147+
await expect(nextImageResponse?.status()).toBe(200)
148+
await expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
149+
})
150+
151+
test('Remote images: domains', async ({ page, simpleNextApp }) => {
152+
const nextImageResponsePromise = page.waitForResponse('**/_next/image**')
153+
154+
await page.goto(`${simpleNextApp.url}/image/remote-domain`)
155+
156+
const nextImageResponse = await nextImageResponsePromise
157+
158+
expect(nextImageResponse.url()).toContain(
159+
`_next/image?url=${encodeURIComponent(
160+
'https://images.pexels.com/photos/406014/pexels-photo-406014.jpeg',
161+
)}`,
162+
)
163+
164+
await expect(nextImageResponse?.status()).toBe(200)
165+
await expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')
166+
})
167+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Image from 'next/image'
2+
3+
export default function Domains() {
4+
return (
5+
<main>
6+
<h1>Remote Images with Netlify CDN</h1>
7+
<Image
8+
src="https://images.pexels.com/photos/406014/pexels-photo-406014.jpeg"
9+
alt="dog up close"
10+
width={300}
11+
height={278}
12+
/>
13+
</main>
14+
)
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Image from 'next/image'
2+
3+
export default function NextImageUsingNetlifyImageCDN() {
4+
return (
5+
<main>
6+
<h1>Remote Images with Netlify CDN</h1>
7+
<Image
8+
src="https://images.unsplash.com/photo-1574870111867-089730e5a72b"
9+
alt="a cute Giraffe"
10+
width={300}
11+
height={278}
12+
/>
13+
</main>
14+
)
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Image from 'next/image'
2+
3+
export default function NextImageUsingNetlifyImageCDN() {
4+
return (
5+
<main>
6+
<h1>Remote Images with Netlify CDN</h1>
7+
<Image
8+
src="https://cdn.pixabay.com/photo/2017/02/20/18/03/cat-2083492_1280.jpg"
9+
alt="a cute Cat"
10+
width={300}
11+
height={183}
12+
/>
13+
</main>
14+
)
15+
}

tests/fixtures/simple-next-app/next.config.js

+13
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ const nextConfig = {
44
eslint: {
55
ignoreDuringBuilds: true,
66
},
7+
images: {
8+
remotePatterns: [
9+
{
10+
protocol: 'https',
11+
hostname: 'images.unsplash.com',
12+
pathname: '/?(photo)-**-**',
13+
},
14+
{
15+
hostname: '*.pixabay.com',
16+
},
17+
],
18+
domains: ['images.pexels.com'],
19+
},
720
}
821

922
module.exports = nextConfig

tests/integration/simple-app.test.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ test<FixtureTestContext>('Test that the simple next app is working', async (ctx)
3737
const blobEntries = await getBlobEntries(ctx)
3838
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual([
3939
'/404',
40-
'/image',
40+
'/image/local',
41+
'/image/remote-domain',
42+
'/image/remote-pattern-1',
43+
'/image/remote-pattern-2',
4144
'/index',
4245
'/other',
4346
'/redirect',

0 commit comments

Comments
 (0)