Skip to content

Commit

Permalink
feat: handle /_next/image through Netlify Image CDN for local images (#…
Browse files Browse the repository at this point in the history
…149)

* image rewrite

* check for default loader

* add back image redirect

* add nextconfig and netlifyconfig

* tmp: revert back to using redirect for lambda until netlify/proxy#1402 is deployed to workaround redirect clash

* refactor: move image-cdn handling to it's own module

* chore: eslint ignore for short param names that we can't change

* test: add netlifyConfig.redirects array to mocked plugin options

* test: add unit test cases for image-cdn

* test: add e2e test for image-cdn

* Revert "tmp: revert back to using redirect for lambda until netlify/proxy#1402 is deployed to workaround redirect clash"

This reverts commit bf3269db27cc0c9e0224bb2777e0f113d31d0741.

* test: assert content-type instead of internal header that might change in e2e test

* fix: use import type when importing types for safety

---------

Co-authored-by: Tatyana <[email protected]>
  • Loading branch information
pieh and taty2010 authored Jan 12, 2024
1 parent ebe579f commit 4bf8641
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 3 deletions.
117 changes: 117 additions & 0 deletions src/build/image-cdn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* eslint-disable id-length */
import type { NetlifyPluginOptions } from '@netlify/build'
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
import { beforeEach, describe, expect, test, vi, TestContext } from 'vitest'

import { setImageConfig } from './image-cdn.js'
import { PluginContext } from './plugin-context.js'

type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>
}
: T

type ImageCDNTestContext = TestContext & {
pluginContext: PluginContext
mockNextConfig?: DeepPartial<NextConfigComplete>
}

describe('Image CDN', () => {
beforeEach<ImageCDNTestContext>((ctx) => {
ctx.mockNextConfig = undefined
ctx.pluginContext = new PluginContext({
netlifyConfig: {
redirects: [],
},
} as unknown as NetlifyPluginOptions)
vi.spyOn(ctx.pluginContext, 'getBuildConfig').mockImplementation(() =>
Promise.resolve((ctx.mockNextConfig ?? {}) as NextConfigComplete),
)
})

test<ImageCDNTestContext>('adds redirect to Netlify Image CDN when default image loader is used', async (ctx) => {
ctx.mockNextConfig = {
images: {
path: '/_next/image',
loader: 'default',
},
}

await setImageConfig(ctx.pluginContext)

expect(ctx.pluginContext.netlifyConfig.redirects).toEqual(
expect.arrayContaining([
{
from: '/_next/image',
query: {
q: ':quality',
url: ':url',
w: ':width',
},
to: '/.netlify/images?url=:url&w=:width&q=:quality',
status: 200,
},
]),
)
})

test<ImageCDNTestContext>('does not add redirect to Netlify Image CDN when non-default loader is used', async (ctx) => {
ctx.mockNextConfig = {
images: {
path: '/_next/image',
loader: 'custom',
loaderFile: './custom-loader.js',
},
}

await setImageConfig(ctx.pluginContext)

expect(ctx.pluginContext.netlifyConfig.redirects).not.toEqual(
expect.arrayContaining([
{
from: '/_next/image',
query: {
q: ':quality',
url: ':url',
w: ':width',
},
to: '/.netlify/images?url=:url&w=:width&q=:quality',
status: 200,
},
]),
)
})

test<ImageCDNTestContext>('handles custom images.path', async (ctx) => {
ctx.mockNextConfig = {
images: {
// Next.js automatically adds basePath to images.path (when user does not set custom `images.path` in their config)
// if user sets custom `images.path` - it will be used as-is (so user need to cover their basePath by themselves
// if they want to have it in their custom image endpoint
// see https://github.com/vercel/next.js/blob/bb105ef4fbfed9d96a93794eeaed956eda2116d8/packages/next/src/server/config.ts#L426-L432)
// either way `images.path` we get is final config with everything combined so we want to use it as-is
path: '/base/path/_custom/image/endpoint',
loader: 'default',
},
}

await setImageConfig(ctx.pluginContext)

expect(ctx.pluginContext.netlifyConfig.redirects).toEqual(
expect.arrayContaining([
{
from: '/base/path/_custom/image/endpoint',
query: {
q: ':quality',
url: ':url',
w: ':width',
},
to: '/.netlify/images?url=:url&w=:width&q=:quality',
status: 200,
},
]),
)
})
})
/* eslint-enable id-length */
22 changes: 22 additions & 0 deletions src/build/image-cdn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PluginContext } from './plugin-context.js'

/**
* Rewrite next/image to netlify image cdn
*/
export const setImageConfig = async (ctx: PluginContext): Promise<void> => {
const {
images: { path: imageEndpointPath, loader: imageLoader },
} = await ctx.getBuildConfig()

if (imageLoader === 'default') {
ctx.netlifyConfig.redirects.push({
from: imageEndpointPath,
// w and q are too short to be used as params with id-length rule
// but we are forced to do so because of the next/image loader decides on their names
// eslint-disable-next-line id-length
query: { url: ':url', w: ':width', q: ':quality' },
to: '/.netlify/images?url=:url&w=:width&q=:quality',
status: 200,
})
}
}
19 changes: 16 additions & 3 deletions src/build/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

import { NetlifyPluginConstants, NetlifyPluginOptions, NetlifyPluginUtils } from '@netlify/build'
import { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js'
import { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
import type {
NetlifyPluginConstants,
NetlifyPluginOptions,
NetlifyPluginUtils,
} from '@netlify/build'
import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js'
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'

const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
const PLUGIN_DIR = join(MODULE_DIR, '../..')
Expand Down Expand Up @@ -49,6 +54,7 @@ export type CacheEntry = {

export class PluginContext {
utils: NetlifyPluginUtils
netlifyConfig: NetlifyPluginOptions['netlifyConfig']
pluginName: string
pluginVersion: string

Expand Down Expand Up @@ -114,6 +120,7 @@ export class PluginContext {
this.pluginVersion = this.packageJSON.version
this.constants = options.constants
this.utils = options.utils
this.netlifyConfig = options.netlifyConfig
}

/** Resolves a path correctly with mono repository awareness */
Expand All @@ -135,6 +142,12 @@ export class PluginContext {
)
}

/** Get Next Config from build output **/
async getBuildConfig(): Promise<NextConfigComplete> {
return JSON.parse(await readFile(join(this.publishDir, 'required-server-files.json'), 'utf-8'))
.config
}

/**
* Get Next.js routes manifest from the build output
*/
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from './build/content/static.js'
import { createEdgeHandlers } from './build/functions/edge.js'
import { createServerHandler } from './build/functions/server.js'
import { setImageConfig } from './build/image-cdn.js'
import { PluginContext } from './build/plugin-context.js'

export const onPreBuild = async (options: NetlifyPluginOptions) => {
Expand All @@ -25,6 +26,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
if (!existsSync(ctx.publishDir)) {
ctx.failBuild('Publish directory not found, please check your netlify.toml')
}
await setImageConfig(ctx)
await saveBuildCache(ctx)

await Promise.all([
Expand Down
14 changes: 14 additions & 0 deletions tests/e2e/simple-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,17 @@ test('Redirects correctly', async ({ page }) => {
await page.goto(`${ctx.url}/redirect`)
await expect(page).toHaveURL(`https://www.netlify.com/`)
})

test('next/image is using Netlify Image CDN', async ({ page }) => {
const nextImageResponsePromise = page.waitForResponse('**/_next/image**')

await page.goto(`${ctx.url}/image`)

const nextImageResponse = await nextImageResponsePromise
expect(nextImageResponse.request().url()).toContain('_next/image?url=%2Fsquirrel.jpg')
// ensure next/image is using Image CDN
// source image is jpg, but when requesting it through Image CDN avif will be returned
await expect(await nextImageResponse.headerValue('content-type')).toEqual('image/avif')

await expectImageWasLoaded(page.locator('img'))
})
10 changes: 10 additions & 0 deletions tests/fixtures/simple-next-app/app/image/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Image from 'next/image'

export default function NextImageUsingNetlifyImageCDN() {
return (
<main>
<h1>Next/Image + Netlify Image CDN</h1>
<Image src="/squirrel.jpg" alt="a cute squirrel (next/image)" width={300} height={278} />
</main>
)
}
1 change: 1 addition & 0 deletions tests/integration/simple-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ test<FixtureTestContext>('Test that the simple next app is working', async (ctx)
'404',
'404.html',
'500.html',
'image',
'index',
'other',
'redirect',
Expand Down
3 changes: 3 additions & 0 deletions tests/utils/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export async function runPluginStep(
// INTERNAL_FUNCTIONS_SRC: '.netlify/functions-internal',
// INTERNAL_EDGE_FUNCTIONS_SRC: '.netlify/edge-functions',
},
netlifyConfig: {
redirects: [],
},
utils: {
build: {
failBuild: (message, options) => {
Expand Down

0 comments on commit 4bf8641

Please sign in to comment.