Skip to content

Commit 276ddf3

Browse files
authored
fix: setting assetPrefix to URL format breaks HMR (#70040)
Backporting: - #68622 - #68681 - #68518
1 parent 9954a21 commit 276ddf3

File tree

8 files changed

+130
-24
lines changed

8 files changed

+130
-24
lines changed

docs/02-app/02-api-reference/05-next-config-js/assetPrefix.mdx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,25 @@ description: Learn how to use the assetPrefix config option to configure your CD
2323
> suited for hosting your application on a sub-path like `/docs`.
2424
> We do not suggest you use a custom Asset Prefix for this use case.
2525
26-
To set up a [CDN](https://en.wikipedia.org/wiki/Content_delivery_network), you can set up an asset prefix and configure your CDN's origin to resolve to the domain that Next.js is hosted on.
27-
28-
Open `next.config.js` and add the `assetPrefix` config:
26+
## Set up a CDN
2927

30-
```js filename="next.config.js"
31-
const isProd = process.env.NODE_ENV === 'production'
28+
To set up a [CDN](https://en.wikipedia.org/wiki/Content_delivery_network), you can set up an asset prefix and configure your CDN's origin to resolve to the domain that Next.js is hosted on.
3229

33-
module.exports = {
34-
// Use the CDN in production and localhost for development.
35-
assetPrefix: isProd ? 'https://cdn.mydomain.com' : undefined,
30+
Open `next.config.mjs` and add the `assetPrefix` config based on the [phase](/docs/app/api-reference/next-config-js#async-configuration):
31+
32+
```js filename="next.config.mjs"
33+
// @ts-check
34+
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants'
35+
36+
export default (phase) => {
37+
const isDev = phase === PHASE_DEVELOPMENT_SERVER
38+
/**
39+
* @type {import('next').NextConfig}
40+
*/
41+
const nextConfig = {
42+
assetPrefix: isDev ? undefined : 'https://cdn.mydomain.com',
43+
}
44+
return nextConfig
3645
}
3746
```
3847

packages/next/src/client/components/react-dev-overlay/internal/helpers/get-socket-url.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@ function getSocketProtocol(assetPrefix: string): string {
88
protocol = new URL(assetPrefix).protocol
99
} catch {}
1010

11-
return protocol === 'http:' ? 'ws' : 'wss'
11+
return protocol === 'http:' ? 'ws:' : 'wss:'
1212
}
1313

1414
export function getSocketUrl(assetPrefix: string | undefined): string {
15-
const { hostname, port } = window.location
16-
const protocol = getSocketProtocol(assetPrefix || '')
1715
const prefix = normalizedAssetPrefix(assetPrefix)
16+
const protocol = getSocketProtocol(assetPrefix || '')
1817

19-
// if original assetPrefix is a full URL with protocol
20-
// we just update to use the correct `ws` protocol
21-
if (assetPrefix?.replace(/^\/+/, '').includes('://')) {
22-
return `${protocol}://${prefix}`
18+
if (URL.canParse(prefix)) {
19+
// since normalized asset prefix is ensured to be a URL format,
20+
// we can safely replace the protocol
21+
return prefix.replace(/^http/, 'ws')
2322
}
2423

25-
return `${protocol}://${hostname}:${port}${prefix}`
24+
const { hostname, port } = window.location
25+
return `${protocol}//${hostname}${port ? `:${port}` : ''}${prefix}`
2626
}

packages/next/src/server/lib/router-server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,15 @@ export async function initialize(opts: {
657657
// assetPrefix overrides basePath for HMR path
658658
if (assetPrefix) {
659659
hmrPrefix = normalizedAssetPrefix(assetPrefix)
660+
661+
if (URL.canParse(hmrPrefix)) {
662+
// remove trailing slash from pathname
663+
// return empty string if pathname is '/'
664+
// to avoid conflicts with '/_next' below
665+
hmrPrefix = new URL(hmrPrefix).pathname.replace(/\/$/, '')
666+
}
660667
}
668+
661669
const isHMRRequest = req.url.startsWith(
662670
ensureLeadingSlash(`${hmrPrefix}/_next/webpack-hmr`)
663671
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { normalizedAssetPrefix } from './normalized-asset-prefix'
2+
3+
describe('normalizedAssetPrefix', () => {
4+
it('should return an empty string when assetPrefix is nullish', () => {
5+
expect(normalizedAssetPrefix(undefined)).toBe('')
6+
})
7+
8+
it('should return an empty string when assetPrefix is an empty string', () => {
9+
expect(normalizedAssetPrefix('')).toBe('')
10+
})
11+
12+
it('should return an empty string when assetPrefix is a single slash', () => {
13+
expect(normalizedAssetPrefix('/')).toBe('')
14+
})
15+
16+
// we expect an empty string because it could be an unnecessary trailing slash
17+
it('should remove leading slash(es) when assetPrefix has more than one', () => {
18+
expect(normalizedAssetPrefix('///path/to/asset')).toBe('/path/to/asset')
19+
})
20+
21+
it('should not remove the leading slash when assetPrefix has only one', () => {
22+
expect(normalizedAssetPrefix('/path/to/asset')).toBe('/path/to/asset')
23+
})
24+
25+
it('should add a leading slash when assetPrefix is missing one', () => {
26+
expect(normalizedAssetPrefix('path/to/asset')).toBe('/path/to/asset')
27+
})
28+
29+
it('should remove all trailing slash(es) when assetPrefix has one', () => {
30+
expect(normalizedAssetPrefix('/path/to/asset///')).toBe('/path/to/asset')
31+
})
32+
33+
it('should return the URL when assetPrefix is a URL', () => {
34+
expect(normalizedAssetPrefix('https://example.com/path/to/asset')).toBe(
35+
'https://example.com/path/to/asset'
36+
)
37+
})
38+
39+
it('should not leave a trailing slash when assetPrefix is a URL with no pathname', () => {
40+
expect(normalizedAssetPrefix('https://example.com')).toBe(
41+
'https://example.com'
42+
)
43+
})
44+
})
Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
export function normalizedAssetPrefix(assetPrefix: string | undefined): string {
2-
const escapedAssetPrefix = assetPrefix?.replace(/^\/+/, '') || false
2+
// remove all leading slashes and trailing slashes
3+
const escapedAssetPrefix = assetPrefix?.replace(/^\/+|\/+$/g, '') || false
34

4-
// assetPrefix as a url
5-
if (escapedAssetPrefix && escapedAssetPrefix.startsWith('://')) {
6-
return escapedAssetPrefix.split('://', 2)[1]
7-
}
8-
9-
// assetPrefix is set to `undefined` or '/'
5+
// if an assetPrefix was '/', we return empty string
6+
// because it could be an unnecessary trailing slash
107
if (!escapedAssetPrefix) {
118
return ''
129
}
1310

14-
// assetPrefix is a common path but escaped so let's add one leading slash
11+
if (URL.canParse(escapedAssetPrefix)) {
12+
const url = new URL(escapedAssetPrefix).toString()
13+
return url.endsWith('/') ? url.slice(0, -1) : url
14+
}
15+
16+
// assuming assetPrefix here is a pathname-style,
17+
// restore the leading slash
1518
return `/${escapedAssetPrefix}`
1619
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Root({ children }) {
2+
return (
3+
<html>
4+
<body>{children}</body>
5+
</html>
6+
)
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <p>before edit</p>
3+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createNext } from 'e2e-utils'
2+
import { findPort, retry } from 'next-test-utils'
3+
4+
describe('app-dir assetPrefix full URL', () => {
5+
let next, forcedPort
6+
beforeAll(async () => {
7+
forcedPort = ((await findPort()) ?? '54321').toString()
8+
9+
next = await createNext({
10+
files: __dirname,
11+
forcedPort,
12+
nextConfig: {
13+
assetPrefix: `http://localhost:${forcedPort}`,
14+
},
15+
})
16+
})
17+
afterAll(() => next.destroy())
18+
19+
it('should not break HMR when asset prefix set to full URL', async () => {
20+
const browser = await next.browser('/')
21+
const text = await browser.elementByCss('p').text()
22+
expect(text).toBe('before edit')
23+
24+
await next.patchFile('app/page.tsx', (content) => {
25+
return content.replace('before', 'after')
26+
})
27+
28+
await retry(async () => {
29+
expect(await browser.elementByCss('p').text()).toBe('after edit')
30+
})
31+
})
32+
})

0 commit comments

Comments
 (0)