mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
## Summary Propagate `maxDuration` to Edge adapter outputs so deployment adapters receive the same route execution limit metadata as Node.js outputs. Normalize Pages Router Edge adapter pathnames that end in `/index` to their public route pathname, for example `/edge-pages/index` to `/edge-pages`. This matters because the functions config manifest stores route metadata under the public pathname, so matching the unnormalized `/index` form would miss `maxDuration`. Add adapter coverage for App Router pages and routes, Pages Router pages and API routes, and both Node.js and Edge runtimes, including the Pages Router `/index` normalization case. ## Verification - `pnpm test-start-turbo test/production/adapter-config/adapter-config.test.ts` - `pnpm test-start-webpack test/production/adapter-config/adapter-config.test.ts` <!-- NEXT_JS_LLM_PR -->
474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
import fs from 'fs'
|
|
import { nextTestSetup } from 'e2e-utils'
|
|
import type { AdapterOutput, NextAdapter } from 'next'
|
|
import { version as nextVersion } from 'next/package.json'
|
|
|
|
describe('adapter-config', () => {
|
|
const { next } = nextTestSetup({
|
|
files: __dirname,
|
|
})
|
|
|
|
it('should apply modifyConfig from adapter', async () => {
|
|
// we apply basePath of "/docs" to ensure modify was called
|
|
const res = await next.fetch('/')
|
|
expect(res.status).toBe(404)
|
|
|
|
const res2 = await next.fetch('/docs/node-pages')
|
|
expect(res2.status).toBe(200)
|
|
expect(await res2.text()).toContain('hello world')
|
|
|
|
expect(next.cliOutput).toContain('called modify config in adapter')
|
|
})
|
|
|
|
it('should call onBuildComplete with correct context', async () => {
|
|
expect(next.cliOutput).toContain('onBuildComplete called')
|
|
|
|
const {
|
|
outputs,
|
|
routing,
|
|
config,
|
|
...ctx
|
|
}: Parameters<NextAdapter['onBuildComplete']>[0] = await next.readJSON(
|
|
'build-complete.json'
|
|
)
|
|
|
|
for (const field of ['distDir', 'projectDir', 'repoRoot']) {
|
|
expect(ctx[field]).toBeString()
|
|
|
|
if (!fs.existsSync(ctx[field])) {
|
|
throw new Error(
|
|
`Invalid dir value provided for ${field} value ${ctx[field]}`
|
|
)
|
|
}
|
|
}
|
|
|
|
expect(ctx.nextVersion).toBe(nextVersion)
|
|
expect(ctx.projectDir).toBe(next.testDir)
|
|
expect(config?.basePath).toBe('/docs')
|
|
|
|
const combinedRouteOutputs = [
|
|
...outputs.appPages,
|
|
...outputs.appRoutes,
|
|
...outputs.pages,
|
|
...outputs.pagesApi,
|
|
]
|
|
|
|
type PageRoutesType =
|
|
| AdapterOutput['APP_PAGE']
|
|
| AdapterOutput['APP_ROUTE']
|
|
| AdapterOutput['PAGES']
|
|
| AdapterOutput['PAGES_API']
|
|
|
|
const outputMap = new Map<string, PageRoutesType>()
|
|
const prerenderOutputs: AdapterOutput['PRERENDER'][] = outputs.prerenders
|
|
const staticOutputs: AdapterOutput['STATIC_FILE'][] = outputs.staticFiles
|
|
const nodeOutputs: PageRoutesType[] = []
|
|
const edgeOutputs: PageRoutesType[] = []
|
|
|
|
for (const routeOutput of combinedRouteOutputs) {
|
|
if (outputMap.has(routeOutput.id)) {
|
|
require('console').error({
|
|
existingOutput: outputMap.get(routeOutput.id),
|
|
newOutput: routeOutput,
|
|
})
|
|
throw new Error(`duplicate id on route outputs ${routeOutput.id}`)
|
|
}
|
|
outputMap.set(routeOutput.id, routeOutput)
|
|
|
|
if (routeOutput.runtime === 'edge') {
|
|
edgeOutputs.push(routeOutput)
|
|
} else if (routeOutput.runtime === 'nodejs') {
|
|
nodeOutputs.push(routeOutput)
|
|
} else {
|
|
require('console').error(routeOutput)
|
|
throw new Error(`Unexpected runtime on output ${routeOutput.runtime}`)
|
|
}
|
|
}
|
|
|
|
expect(nodeOutputs.length).toBeGreaterThan(0)
|
|
expect(edgeOutputs.length).toBeGreaterThan(0)
|
|
expect(staticOutputs.length).toBeGreaterThan(0)
|
|
expect(prerenderOutputs.length).toBeGreaterThan(0)
|
|
|
|
const expectedRouteConfigs = [
|
|
{
|
|
pathname: '/docs/node-app',
|
|
type: 'APP_PAGE',
|
|
runtime: 'nodejs',
|
|
maxDuration: 10,
|
|
},
|
|
{
|
|
pathname: '/docs/edge-app',
|
|
type: 'APP_PAGE',
|
|
runtime: 'edge',
|
|
maxDuration: 20,
|
|
},
|
|
{
|
|
pathname: '/docs/node-route',
|
|
type: 'APP_ROUTE',
|
|
runtime: 'nodejs',
|
|
maxDuration: 30,
|
|
},
|
|
{
|
|
pathname: '/docs/edge-route',
|
|
type: 'APP_ROUTE',
|
|
runtime: 'edge',
|
|
maxDuration: 40,
|
|
},
|
|
{
|
|
pathname: '/docs/node-pages',
|
|
type: 'PAGES',
|
|
runtime: 'nodejs',
|
|
maxDuration: 50,
|
|
},
|
|
{
|
|
pathname: '/docs/edge-pages',
|
|
type: 'PAGES',
|
|
runtime: 'edge',
|
|
maxDuration: 60,
|
|
},
|
|
{
|
|
pathname: '/docs/api/node-pages',
|
|
type: 'PAGES_API',
|
|
runtime: 'nodejs',
|
|
maxDuration: 70,
|
|
},
|
|
{
|
|
pathname: '/docs/api/edge-pages',
|
|
type: 'PAGES_API',
|
|
runtime: 'edge',
|
|
maxDuration: 80,
|
|
},
|
|
] as const
|
|
|
|
for (const {
|
|
pathname,
|
|
type,
|
|
runtime,
|
|
maxDuration,
|
|
} of expectedRouteConfigs) {
|
|
expect(combinedRouteOutputs).toContainEqual(
|
|
expect.objectContaining({
|
|
pathname,
|
|
type,
|
|
runtime,
|
|
config: expect.objectContaining({ maxDuration }),
|
|
})
|
|
)
|
|
}
|
|
|
|
for (const output of staticOutputs) {
|
|
expect(output.id).toBeTruthy()
|
|
|
|
if (output.filePath.endsWith('.html')) {
|
|
expect(output.pathname.endsWith('.html')).toBe(false)
|
|
} else if (output.pathname.endsWith('.rsc')) {
|
|
expect(output.filePath.endsWith('rsc-fallback.json')).toBe(true)
|
|
} else if (output.filePath.endsWith('.body')) {
|
|
// Static metadata files (e.g., /icon.png, /manifest.json, /sitemap.xml) are output as static files
|
|
expect(output.pathname).toMatch(
|
|
/\.(png|jpg|jpeg|ico|svg|gif|json|webmanifest|xml|txt)$/
|
|
)
|
|
} else {
|
|
expect(output.pathname).toMatch(/^\/docs\/_next\/static/)
|
|
}
|
|
// ensure / -> /index normalizing is correct
|
|
expect(output.pathname.includes('/.')).toBe(false)
|
|
|
|
const stats = await fs.promises.stat(output.filePath)
|
|
expect(stats.isFile()).toBe(true)
|
|
}
|
|
|
|
// Verify static metadata files are output as static files, not prerenders
|
|
// Test icon.png
|
|
const iconStaticFile = staticOutputs.find(
|
|
(item) => item.pathname === '/docs/icon.png'
|
|
)
|
|
expect(iconStaticFile).toBeDefined()
|
|
expect(iconStaticFile?.filePath).toMatch(/\.body$/)
|
|
|
|
// Static metadata images should NOT be in prerenders
|
|
const iconPrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs/icon.png'
|
|
)
|
|
expect(iconPrerender).toBeUndefined()
|
|
|
|
// Static metadata images should NOT be in appRoutes
|
|
const iconAppRoute = outputs.appRoutes.find(
|
|
(item) =>
|
|
item.pathname === '/docs/icon.png' ||
|
|
item.pathname === '/docs/icon.png.rsc'
|
|
)
|
|
expect(iconAppRoute).toBeUndefined()
|
|
|
|
// Test favicon.ico
|
|
const faviconStaticFile = staticOutputs.find(
|
|
(item) => item.pathname === '/docs/favicon.ico'
|
|
)
|
|
expect(faviconStaticFile).toBeDefined()
|
|
expect(faviconStaticFile?.filePath).toMatch(/\.body$/)
|
|
|
|
const faviconPrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs/favicon.ico'
|
|
)
|
|
expect(faviconPrerender).toBeUndefined()
|
|
|
|
const faviconAppRoute = outputs.appRoutes.find(
|
|
(item) =>
|
|
item.pathname === '/docs/favicon.ico' ||
|
|
item.pathname === '/docs/favicon.ico.rsc'
|
|
)
|
|
expect(faviconAppRoute).toBeUndefined()
|
|
|
|
// Test opengraph-image.png
|
|
const ogImageStaticFile = staticOutputs.find(
|
|
(item) => item.pathname === '/docs/opengraph-image.png'
|
|
)
|
|
expect(ogImageStaticFile).toBeDefined()
|
|
expect(ogImageStaticFile?.filePath).toMatch(/\.body$/)
|
|
|
|
const ogImagePrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs/opengraph-image.png'
|
|
)
|
|
expect(ogImagePrerender).toBeUndefined()
|
|
|
|
const ogImageAppRoute = outputs.appRoutes.find(
|
|
(item) =>
|
|
item.pathname === '/docs/opengraph-image.png' ||
|
|
item.pathname === '/docs/opengraph-image.png.rsc'
|
|
)
|
|
expect(ogImageAppRoute).toBeUndefined()
|
|
|
|
// Test manifest.json
|
|
const manifestStaticFile = staticOutputs.find(
|
|
(item) => item.pathname === '/docs/manifest.json'
|
|
)
|
|
expect(manifestStaticFile).toBeDefined()
|
|
expect(manifestStaticFile?.filePath).toMatch(/\.body$/)
|
|
|
|
const manifestPrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs/manifest.json'
|
|
)
|
|
expect(manifestPrerender).toBeUndefined()
|
|
|
|
const manifestAppRoute = outputs.appRoutes.find(
|
|
(item) =>
|
|
item.pathname === '/docs/manifest.json' ||
|
|
item.pathname === '/docs/manifest.json.rsc'
|
|
)
|
|
expect(manifestAppRoute).toBeUndefined()
|
|
|
|
// Test sitemap.xml
|
|
const sitemapStaticFile = staticOutputs.find(
|
|
(item) => item.pathname === '/docs/sitemap.xml'
|
|
)
|
|
expect(sitemapStaticFile).toBeDefined()
|
|
expect(sitemapStaticFile?.filePath).toMatch(/\.body$/)
|
|
|
|
const sitemapPrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs/sitemap.xml'
|
|
)
|
|
expect(sitemapPrerender).toBeUndefined()
|
|
|
|
const sitemapAppRoute = outputs.appRoutes.find(
|
|
(item) =>
|
|
item.pathname === '/docs/sitemap.xml' ||
|
|
item.pathname === '/docs/sitemap.xml.rsc'
|
|
)
|
|
expect(sitemapAppRoute).toBeUndefined()
|
|
|
|
// Test robots.txt
|
|
const robotsStaticFile = staticOutputs.find(
|
|
(item) => item.pathname === '/docs/robots.txt'
|
|
)
|
|
expect(robotsStaticFile).toBeDefined()
|
|
expect(robotsStaticFile?.filePath).toMatch(/\.body$/)
|
|
|
|
const robotsPrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs/robots.txt'
|
|
)
|
|
expect(robotsPrerender).toBeUndefined()
|
|
|
|
const robotsAppRoute = outputs.appRoutes.find(
|
|
(item) =>
|
|
item.pathname === '/docs/robots.txt' ||
|
|
item.pathname === '/docs/robots.txt.rsc'
|
|
)
|
|
expect(robotsAppRoute).toBeUndefined()
|
|
|
|
for (const prerenderOutput of prerenderOutputs) {
|
|
try {
|
|
expect(prerenderOutput.parentOutputId).toBeTruthy()
|
|
if (prerenderOutput.fallback) {
|
|
if (
|
|
'filePath' in prerenderOutput.fallback &&
|
|
prerenderOutput.fallback.filePath
|
|
) {
|
|
const stats = await fs.promises.stat(
|
|
prerenderOutput.fallback.filePath
|
|
)
|
|
expect(stats.isFile()).toBe(true)
|
|
}
|
|
expect(prerenderOutput.fallback.initialRevalidate).toBeDefined()
|
|
}
|
|
|
|
expect(typeof prerenderOutput.config.bypassToken).toBe('string')
|
|
expect(Array.isArray(prerenderOutput.config.allowHeader)).toBe(true)
|
|
expect(Array.isArray(prerenderOutput.config.allowQuery)).toBe(true)
|
|
// ensure / -> /index normalizing is correct
|
|
expect(prerenderOutput.pathname.includes('/.')).toBe(false)
|
|
} catch (err) {
|
|
require('console').error(`invalid prerender ${prerenderOutput.id}`, err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const indexPrerender = prerenderOutputs.find(
|
|
(item) => item.pathname === '/docs'
|
|
)
|
|
expect(indexPrerender?.fallback?.initialHeaders).toEqual({
|
|
'content-type': 'text/html; charset=utf-8',
|
|
vary: 'rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch',
|
|
'x-next-cache-tags': '_N_T_/layout,_N_T_/page,_N_T_/,_N_T_/index',
|
|
'x-nextjs-prerender': '1',
|
|
'x-nextjs-stale-time': '300',
|
|
})
|
|
expect(indexPrerender?.fallback?.initialRevalidate).toBe(false)
|
|
|
|
for (const route of nodeOutputs) {
|
|
try {
|
|
expect(route.id).toBeString()
|
|
expect(route.config).toBeObject()
|
|
expect(route.pathname).toBeString()
|
|
expect(route.runtime).toBe('nodejs')
|
|
// ensure / -> /index normalizing is correct
|
|
expect(route.pathname.includes('/.')).toBe(false)
|
|
|
|
const stats = await fs.promises.stat(route.filePath)
|
|
expect(stats.isFile()).toBe(true)
|
|
|
|
const missingAssets: string[] = []
|
|
|
|
for (const filePath of Object.values(route.assets)) {
|
|
if (!fs.existsSync(filePath)) {
|
|
missingAssets.push(filePath)
|
|
}
|
|
}
|
|
|
|
expect(missingAssets).toEqual([])
|
|
} catch (err) {
|
|
require('console').error(`Invalid fields for ${route.id}`, route, err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Verify vendored context files are traced in assets for app-page and pages outputs
|
|
const appPageOutput = outputs.appPages.find(
|
|
(output) =>
|
|
output.pathname === '/docs/node-app' && output.runtime === 'nodejs'
|
|
)
|
|
const pagesOutput = outputs.pages.find(
|
|
(output) =>
|
|
output.pathname === '/docs/node-pages' && output.runtime === 'nodejs'
|
|
)
|
|
|
|
expect(appPageOutput).toBeDefined()
|
|
expect(pagesOutput).toBeDefined()
|
|
|
|
// Check that vendored context files are included in assets
|
|
const appPageAssets = Object.values(appPageOutput!.assets)
|
|
const pagesAssets = Object.values(pagesOutput!.assets)
|
|
|
|
const appPageVendoredContexts = appPageAssets.filter((asset) =>
|
|
asset.includes('vendored/contexts/')
|
|
)
|
|
const pagesVendoredContexts = pagesAssets.filter((asset) =>
|
|
asset.includes('vendored/contexts/')
|
|
)
|
|
|
|
// app-page should have vendored context files traced
|
|
expect(appPageVendoredContexts.length).toBeGreaterThan(0)
|
|
// pages should have vendored context files traced
|
|
expect(pagesVendoredContexts.length).toBeGreaterThan(0)
|
|
|
|
for (const route of edgeOutputs) {
|
|
try {
|
|
expect(route.id).toBeString()
|
|
expect(route.config).toBeObject()
|
|
expect(route.pathname).toBeString()
|
|
// ensure / -> /index normalizing is correct
|
|
expect(route.pathname.includes('/.')).toBe(false)
|
|
expect(route.runtime).toBe('edge')
|
|
expect(route.config.env).toEqual(
|
|
expect.objectContaining({
|
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: expect.toBeString(),
|
|
__NEXT_BUILD_ID: expect.toBeString(),
|
|
__NEXT_PREVIEW_MODE_ENCRYPTION_KEY: expect.toBeString(),
|
|
__NEXT_PREVIEW_MODE_ID: expect.toBeString(),
|
|
__NEXT_PREVIEW_MODE_SIGNING_KEY: expect.toBeString(),
|
|
})
|
|
)
|
|
const edgeRuntime = (
|
|
route as PageRoutesType & {
|
|
edgeRuntime?: {
|
|
modulePath: string
|
|
entryKey: string
|
|
handlerExport: string
|
|
}
|
|
}
|
|
).edgeRuntime
|
|
expect(edgeRuntime).toEqual(
|
|
expect.objectContaining({
|
|
modulePath: expect.toBeString(),
|
|
entryKey: expect.toBeString(),
|
|
handlerExport: 'handler',
|
|
})
|
|
)
|
|
expect(edgeRuntime?.entryKey.startsWith('middleware_')).toBe(true)
|
|
expect(edgeRuntime?.modulePath).toBe(route.filePath)
|
|
|
|
const stats = await fs.promises.stat(route.filePath)
|
|
expect(stats.isFile()).toBe(true)
|
|
|
|
const missingAssets: string[] = []
|
|
|
|
for (const filePath of Object.values(route.assets)) {
|
|
if (!fs.existsSync(filePath)) {
|
|
missingAssets.push(filePath)
|
|
}
|
|
}
|
|
|
|
expect(missingAssets).toEqual([])
|
|
} catch (err) {
|
|
require('console').error(`Invalid fields for ${route.id}`, route, err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
expect(routing).toEqual({
|
|
beforeMiddleware: expect.toBeArray(),
|
|
middlewareMatchers: expect.toBeArray(),
|
|
beforeFiles: expect.toBeArray(),
|
|
afterFiles: expect.toBeArray(),
|
|
dynamicRoutes: expect.toBeArray(),
|
|
onMatch: expect.toBeArray(),
|
|
fallback: expect.toBeArray(),
|
|
shouldNormalizeNextData: expect.toBeBoolean(),
|
|
rsc: expect.toBeObject(),
|
|
})
|
|
})
|
|
|
|
it('should propagate preferredRegion to adapter output', async () => {
|
|
const { outputs }: Parameters<NextAdapter['onBuildComplete']>[0] =
|
|
await next.readJSON('build-complete.json')
|
|
|
|
const preferredRegionRoute = outputs.appRoutes.find(
|
|
(output) => output.pathname === '/docs/preferred-region'
|
|
)
|
|
|
|
expect(preferredRegionRoute).toBeDefined()
|
|
expect(preferredRegionRoute?.runtime).toBe('edge')
|
|
expect(preferredRegionRoute?.config.preferredRegion).toEqual(['cdg1'])
|
|
})
|
|
})
|