Files
next.js/test/e2e/custom-server/custom-server.test.ts
Tim NeutkensandGitHub 20ac9ce9cf Deprecate undocumented custom server methods (#94348)
### What?

Deprecate undocumented custom-server methods exposed by `next()` while
preserving their runtime behavior.

- Add TypeScript deprecation annotations and one-time runtime warnings
for `setAssetPrefix()`, logging helpers, `revalidate()`, and legacy
`render*` helpers.
- Extend custom-server tests to cover warnings and warning
deduplication.
- Move unrelated test fixtures away from deprecated custom-server calls.

### Why?

These methods are not part of the documented custom-server API, but
existing applications may still rely on them. Warning-only deprecation
gives users migration guidance without introducing a breaking runtime
change.

### How?

Warnings are emitted only through `NextCustomServer`, so framework-owned
`NextServer` usage remains quiet. The warning text points `render*`
callers toward `app.getRequestHandler()`, `setAssetPrefix()` callers
toward configuration, and logging and revalidation callers toward
supported application APIs.

The tests retain compatibility assertions for existing behavior, add
deliberate invocations for previously uncovered methods, verify
once-per-process warning deduplication, and remove incidental warning
sources in unrelated fixtures.

<!-- NEXT_JS_LLM_PR -->
2026-06-03 12:23:36 +02:00

399 lines
13 KiB
TypeScript

/* eslint-disable jest/no-standalone-expect */
import { nextTestSetup, isNextDev } from 'e2e-utils'
import { retry } from 'next-test-utils'
import cheerio from 'cheerio'
import https from 'https'
const sharedDeps = { 'get-port': '5.1.1' }
const sharedNodeEnv = isNextDev ? 'development' : 'production'
const deprecatedWarning = (method: string) =>
`The \`app.${method}()\` method is deprecated in custom servers.`
describe.each([
{ title: 'HTTP', useHttps: 'false' },
{ title: 'HTTPS', useHttps: 'true' },
])('Custom Server $title', ({ title, useHttps }) => {
// The HTTPS server presents a self-signed certificate that the test process
// does not trust. Pass a custom agent that skips cert verification on every
// HTTPS request. Setting `process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'`
// does not take effect from inside the Jest VM context.
const agent =
useHttps === 'true'
? new https.Agent({ rejectUnauthorized: false })
: undefined
describe('with dynamic assetPrefix', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: { USE_HTTPS: useHttps, NODE_ENV: sharedNodeEnv },
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('should serve internal file from render', async () => {
const html = await next.render('/static/hello.txt', undefined, { agent })
expect(html).toMatch(/hello world/)
})
it('should handle render with undefined query', async () => {
const html = await next.render('/no-query', undefined, { agent })
expect(html).toMatch(/"query":/)
})
it('should set the assetPrefix dynamically', async () => {
const normalUsage = await next.render('/asset', undefined, { agent })
expect(normalUsage).not.toMatch(/127\.0\.0\.1/)
const dynamicUsage = await next.render(
'/asset?setAssetPrefix=1',
undefined,
{ agent }
)
expect(dynamicUsage).toMatch(/127\.0\.0\.1/)
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning('setAssetPrefix'))
})
})
it('should handle null assetPrefix accordingly', async () => {
const normalUsage = await next.render(
'/asset?setEmptyAssetPrefix=1',
undefined,
{ agent }
)
expect(normalUsage).toMatch(/"\/_next/)
})
it('should set the assetPrefix to a given request', async () => {
for (let lc = 0; lc < 10; lc++) {
// Make requests sequential to avoid race condition with setAssetPrefix
const normalUsage = await next.render('/asset', undefined, { agent })
const dynamicUsage = await next.render(
'/asset?setAssetPrefix=1',
undefined,
{ agent }
)
expect(normalUsage).not.toMatch(/127\.0\.0\.1/)
expect(dynamicUsage).toMatch(/127\.0\.0\.1/)
}
await retry(async () => {
expect(
next.cliOutput.split(deprecatedWarning('setAssetPrefix')).length - 1
).toBe(1)
})
})
it('should render nested index', async () => {
const html = await next.render('/dashboard', undefined, { agent })
expect(html).toMatch(/made it to dashboard/)
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning('render'))
})
})
it('should warn once for repeated render calls', async () => {
await next.render('/dashboard', undefined, { agent })
await next.render('/dashboard', undefined, { agent })
await retry(async () => {
expect(
next.cliOutput.split(deprecatedWarning('render')).length - 1
).toBe(1)
})
})
it('should handle custom urls with requests handler', async () => {
const html = await next.render(
'/custom-url-with-request-handler',
undefined,
{ agent }
)
expect(html).toMatch(/made it to dashboard/)
})
it.skip('should contain customServer in NEXT_DATA', async () => {
const html = await next.render('/', undefined, { agent })
const $ = cheerio.load(html)
expect(JSON.parse($('#__NEXT_DATA__').text()).customServer).toBe(true)
})
it.each(['/', '/no-query'])(
'should handle compression for route %s',
async (route) => {
const response = await next.fetch(route, { agent })
expect(response.headers.get('Content-Encoding')).toBe('gzip')
}
)
it('should read the expected url protocol in middleware', async () => {
const path = '/middleware-augmented'
const response = await next.fetch(path, { agent })
const port = new URL(next.url).port
expect(response.headers.get('x-original-url')).toBe(
`${useHttps === 'true' ? 'https' : 'http'}://localhost:${port}${path}`
)
})
})
;(isNextDev ? describe.skip : describe)('with generateEtags enabled', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: {
USE_HTTPS: useHttps,
GENERATE_ETAGS: 'true',
NODE_ENV: sharedNodeEnv,
},
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('response includes etag header', async () => {
const response = await next.fetch('/', { agent })
expect(response.headers.get('etag')).toBeTruthy()
})
})
describe('with generateEtags disabled', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: {
USE_HTTPS: useHttps,
GENERATE_ETAGS: 'false',
NODE_ENV: sharedNodeEnv,
},
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('response does not include etag header', async () => {
const response = await next.fetch('/', { agent })
expect(response.headers.get('etag')).toBeNull()
})
})
if (useHttps === 'false') {
;(isNextDev ? describe : describe.skip)('HMR with custom server', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: { USE_HTTPS: useHttps, NODE_ENV: sharedNodeEnv },
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('Should support HMR when rendering with /index pathname', async () => {
const browser = await next.browser('/test-index-hmr')
const text = await browser.elementByCss('#go-asset').text()
const logs = await browser.log()
expect(text).toBe('Asset')
expect(
logs.some((log) =>
log.message.includes(
'ReactDOM.hydrate is no longer supported in React 18'
)
)
).toBe(false)
const originalContent = await next.readFile('pages/index.js')
await next.patchFile(
'pages/index.js',
originalContent.replace('Asset', 'Asset!!')
)
try {
await retry(async () => {
expect(await browser.elementByCss('#go-asset').text()).toMatch(
/Asset!!/
)
})
} finally {
await next.patchFile('pages/index.js', originalContent)
}
})
})
}
describe('Error when rendering without starting slash', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: { USE_HTTPS: useHttps, NODE_ENV: sharedNodeEnv },
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
;(isNextDev ? it : it.skip)('should warn in development mode', async () => {
const cliOutputBefore = next.cliOutput.length
const html = await next.render('/no-slash', undefined, { agent })
expect(html).toContain('made it to dashboard')
await retry(async () => {
expect(next.cliOutput.slice(cliOutputBefore)).toContain(
'Cannot render page with path "dashboard"'
)
})
})
;(isNextDev ? it.skip : it)('should warn in production mode', async () => {
const cliOutputBefore = next.cliOutput.length
const html = await next.render('/no-slash', undefined, { agent })
expect(html).toContain('made it to dashboard')
await retry(async () => {
expect(next.cliOutput.slice(cliOutputBefore)).toContain(
'Cannot render page with path "dashboard"'
)
})
})
})
describe('with a custom fetch polyfill', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: {
USE_HTTPS: useHttps,
POLYFILL_FETCH: 'true',
NODE_ENV: sharedNodeEnv,
},
dependencies: { ...sharedDeps, 'node-fetch': '2.6.7' },
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('should serve internal file from render', async () => {
const html = await next.render('/static/hello.txt', undefined, { agent })
expect(html).toMatch(/hello world/)
})
})
describe('unhandled rejection', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: { USE_HTTPS: useHttps, NODE_ENV: sharedNodeEnv },
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('stderr should include error message and stack trace', async () => {
const cliOutputBefore = next.cliOutput.length
await next.fetch('/unhandled-rejection', { agent })
await retry(async () => {
const newOutput = next.cliOutput.slice(cliOutputBefore)
expect(newOutput).toContain('unhandledRejection')
})
const newOutput = next.cliOutput.slice(cliOutputBefore)
expect(newOutput).toContain(
'unhandledRejection: Error: unhandled rejection'
)
expect(newOutput).toMatch(/server\.js:\d+:\d+/)
})
})
describe('legacy NextCustomServer methods', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
startCommand: 'node server.js',
serverReadyPattern: /- Local:/,
env: { USE_HTTPS: useHttps, NODE_ENV: sharedNodeEnv },
dependencies: sharedDeps,
skipDeployment: true,
disableAutoSkewProtection: true,
})
if (skipped) return
it('NextCustomServer.renderToHTML', async () => {
const rawHTML = await next.render(
'/legacy-methods/render-to-html?q=2',
undefined,
{ agent }
)
const $ = cheerio.load(rawHTML)
const text = $('p').text()
expect(text).toContain('made it to dynamic dashboard')
expect(text).toContain('query param: 1')
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning('renderToHTML'))
})
})
it('NextCustomServer.render404', async () => {
const html = await next.render('/legacy-methods/render404', undefined, {
agent,
})
expect(html).toContain('made it to 404')
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning('render404'))
})
})
it('NextCustomServer.renderError', async () => {
const html = await next.render(
'/legacy-methods/render-error',
undefined,
{ agent }
)
if (isNextDev) {
expect(html).toContain('Error: kaboom')
} else {
expect(html).toContain('made it to 500')
}
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning('renderError'))
})
})
it('NextCustomServer.renderErrorToHTML', async () => {
const html = await next.render(
'/legacy-methods/render-error-to-html',
undefined,
{ agent }
)
if (isNextDev) {
expect(html).toContain('Error: kaboom')
} else {
expect(html).toContain('made it to 500')
}
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning('renderErrorToHTML'))
})
})
it.each([
['logError', '/legacy-methods/log-error'],
[
'logErrorWithOriginalStack',
'/legacy-methods/log-error-with-original-stack',
],
['revalidate', '/legacy-methods/revalidate'],
])('warns for NextCustomServer.%s', async (method, path) => {
await next.fetch(path, { agent })
await retry(async () => {
expect(next.cliOutput).toContain(deprecatedWarning(method))
})
})
})
})