fix(#38743): config.runtime support template literal (#38750)

## Bug

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

Fixes #38743.
Fixes: https://github.com/vercel/next.js/pull/38750

The PR adds basic `TemplateLiteral` support for static analysis.

The corresponding re-production of #38743 has also been implemented in e2e tests.
This commit is contained in:
Sukka 2022-07-22 03:56:52 +08:00 committed by GitHub
parent 366a04b7ac
commit 02c78a5c15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 213 additions and 41 deletions

View file

@ -1,39 +1,119 @@
# Invalid Page Config
# Invalid Page / API Route Config
#### Why This Error Occurred
In one of your pages you did `export const config` with an invalid value.
In one of your pages or API Routes you did `export const config` with an invalid value.
#### Possible Ways to Fix It
The page's config must be an object initialized directly when being exported and not modified dynamically.
The config object must only contains static constant literals without expressions.
This is not allowed
<table>
<thead>
<tr>
<th>Not Allowed</th>
<th>Allowed</th>
</tr>
</thead>
<tbody>
<tr>
<td>
```js
// `config` should be an object
export const config = 'hello world'
```
This is not allowed
</td>
<td>
```js
const config = {}
config.amp = true
export const config = {}
```
This is not allowed
</td>
</tr>
<tr>
<td>
```js
export const config = {}
// `config.amp` is defined after `config` is exported
config.amp = true
// `config.amp` contains a dynamic expression
export const config = {
amp: 1 + 1 > 2,
}
```
</td>
<td>
```js
export const config = {
amp: true,
}
export const config = {
amp: false,
}
```
</td>
</tr>
<tr>
<td>
```js
// `config.runtime` contains a dynamic expression
export const config = {
runtime: `node${'js'}`,
}
```
</td>
<td>
```js
export const config = {
runtime: 'nodejs',
}
export const config = {
runtime: `nodejs`,
}
```
</td>
</tr>
<tr>
<td>
```js
// Re-exported `config` is not allowed
export { config } from '../config'
```
This is allowed
</td>
<td>
```js
export const config = { amp: true }
export const config = {}
```
</td>
</tr>
</tbody>
</table>
### Useful Links
- [Enabling AMP Support](https://nextjs.org/docs/advanced-features/amp-support/introduction)
- [API Middlewares](https://nextjs.org/docs/api-routes/api-middlewares)
- [Switchable Runtime](https://nextjs.org/docs/advanced-features/react-18/switchable-runtime)

View file

@ -11,6 +11,7 @@ import type {
ObjectExpression,
RegExpLiteral,
StringLiteral,
TemplateLiteral,
VariableDeclaration,
} from '@swc/core'
@ -60,26 +61,6 @@ export function extractExportedConstValue(
throw new NoSuchDeclarationError()
}
/**
* A wrapper on top of `extractExportedConstValue` that returns undefined
* instead of throwing when the thrown error is known.
*/
export function tryToExtractExportedConstValue(
module: Module,
exportedName: string
) {
try {
return extractExportedConstValue(module, exportedName)
} catch (error) {
if (
error instanceof UnsupportedValueError ||
error instanceof NoSuchDeclarationError
) {
return undefined
}
}
}
function isExportDeclaration(node: Node): node is ExportDeclaration {
return node.type === 'ExportDeclaration'
}
@ -124,8 +105,12 @@ function isRegExpLiteral(node: Node): node is RegExpLiteral {
return node.type === 'RegExpLiteral'
}
class UnsupportedValueError extends Error {}
class NoSuchDeclarationError extends Error {}
function isTemplateLiteral(node: Node): node is TemplateLiteral {
return node.type === 'TemplateLiteral'
}
export class UnsupportedValueError extends Error {}
export class NoSuchDeclarationError extends Error {}
function extractValue(node: Node): any {
if (isNullLiteral(node)) {
@ -191,6 +176,25 @@ function extractValue(node: Node): any {
}
return obj
} else if (isTemplateLiteral(node)) {
// e.g. `abc`
if (node.expressions.length !== 0) {
// TODO: should we add support for `${'e'}d${'g'}'e'`?
throw new UnsupportedValueError()
}
// When TemplateLiteral has 0 expressions, the length of quasis is always 1.
// Because when parsing TemplateLiteral, the parser yields the first quasi,
// then the first expression, then the next quasi, then the next expression, etc.,
// until the last quasi.
// Thus if there is no expression, the parser ends at the frst and also last quasis
//
// A "cooked" interpretation where backslashes have special meaning, while a
// "raw" interpretation where backslashes do not have special meaning
// https://exploringjs.com/impatient-js/ch_template-literals.html#template-strings-cooked-vs-raw
const [{ cooked, raw }] = node.quasis
return cooked ?? raw
} else {
throw new UnsupportedValueError()
}

View file

@ -1,6 +1,9 @@
import { isServerRuntime, ServerRuntime } from '../../server/config-shared'
import type { NextConfig } from '../../server/config-shared'
import { tryToExtractExportedConstValue } from './extract-const-value'
import {
extractExportedConstValue,
UnsupportedValueError,
} from './extract-const-value'
import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
import { parseModule } from './parse-module'
import { promises as fs } from 'fs'
@ -32,13 +35,23 @@ export async function getPageStaticInfo(params: {
isDev?: boolean
page?: string
}): Promise<PageStaticInfo> {
const { isDev, pageFilePath, nextConfig } = params
const { isDev, pageFilePath, nextConfig, page } = params
const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || ''
if (/runtime|getStaticProps|getServerSideProps|matcher/.test(fileContent)) {
const swcAST = await parseModule(pageFilePath, fileContent)
const { ssg, ssr } = checkExports(swcAST)
const config = tryToExtractExportedConstValue(swcAST, 'config') || {}
// default / failsafe value for config
let config: any = {}
try {
config = extractExportedConstValue(swcAST, 'config')
} catch (e) {
if (e instanceof UnsupportedValueError) {
warnAboutUnsupportedValue(pageFilePath, page)
}
// `export config` doesn't exist, or other unknown error throw by swc, silence them
}
if (
typeof config.runtime !== 'string' &&
@ -218,3 +231,19 @@ function warnAboutExperimentalEdgeApiFunctions() {
}
let warnedAboutExperimentalEdgeApiFunctions = false
const warnedUnsupportedValueMap = new Map<string, boolean>()
function warnAboutUnsupportedValue(
pageFilePath: string,
page: string | undefined
) {
if (warnedUnsupportedValueMap.has(pageFilePath)) {
return
}
Log.warn(
`You have exported a \`config\` field in ${
page ? `route "${page}"` : `"${pageFilePath}"`
} that Next.js can't recognize, so it will be ignored. See: https://nextjs.org/docs/messages/invalid-page-config`
)
warnedUnsupportedValueMap.set(pageFilePath, true)
}

View file

@ -115,11 +115,15 @@ describe('Switchable runtime', () => {
)
})
it('should build /api/hello as an api route with edge runtime', async () => {
const response = await fetchViaHTTP(context.appPort, '/api/hello')
const text = await response.text()
it('should build /api/hello and /api/edge as an api route with edge runtime', async () => {
let response = await fetchViaHTTP(context.appPort, '/api/hello')
let text = await response.text()
expect(text).toMatch(/Hello from .+\/api\/hello/)
response = await fetchViaHTTP(context.appPort, '/api/edge')
text = await response.text()
expect(text).toMatch(/Returned by Edge API Route .+\/api\/edge/)
if (!(global as any).isNextDeploy) {
const manifest = await readJson(
join(context.appDir, '.next/server/middleware-manifest.json')
@ -137,6 +141,17 @@ describe('Switchable runtime', () => {
regexp: '^/api/hello$',
wasm: [],
},
'/api/edge': {
env: [],
files: [
'server/edge-runtime-webpack.js',
'server/pages/api/edge.js',
],
name: 'pages/api/edge',
page: '/api/edge',
regexp: '^/api/edge$',
wasm: [],
},
},
})
}
@ -235,11 +250,15 @@ describe('Switchable runtime', () => {
})
})
it('should build /api/hello as an api route with edge runtime', async () => {
const response = await fetchViaHTTP(context.appPort, '/api/hello')
const text = await response.text()
it('should build /api/hello and /api/edge as an api route with edge runtime', async () => {
let response = await fetchViaHTTP(context.appPort, '/api/hello')
let text = await response.text()
expect(text).toMatch(/Hello from .+\/api\/hello/)
response = await fetchViaHTTP(context.appPort, '/api/edge')
text = await response.text()
expect(text).toMatch(/Returned by Edge API Route .+\/api\/edge/)
if (!(global as any).isNextDeploy) {
const manifest = await readJson(
join(context.appDir, '.next/server/middleware-manifest.json')
@ -257,6 +276,17 @@ describe('Switchable runtime', () => {
regexp: '^/api/hello$',
wasm: [],
},
'/api/edge': {
env: [],
files: [
'server/edge-runtime-webpack.js',
'server/pages/api/edge.js',
],
name: 'pages/api/edge',
page: '/api/edge',
regexp: '^/api/edge$',
wasm: [],
},
},
})
}

View file

@ -0,0 +1,7 @@
export default (req) => {
return new Response(`Returned by Edge API Route ${req.url}`)
}
export const config = {
runtime: `experimental-edge`,
}

View file

@ -4,7 +4,7 @@ import path from 'path'
describe('Exported runtimes value validation', () => {
test('fails to build on malformed input', async () => {
const result = await nextBuild(
path.resolve(__dirname, './app'),
path.resolve(__dirname, './invalid-runtime/app'),
undefined,
{ stdout: true, stderr: true }
)
@ -15,4 +15,19 @@ describe('Exported runtimes value validation', () => {
),
})
})
test('warns on unrecognized runtimes value', async () => {
const result = await nextBuild(
path.resolve(__dirname, './unsupported-syntax/app'),
undefined,
{ stdout: true, stderr: true }
)
expect(result).toMatchObject({
code: 0,
stderr: expect.stringContaining(
`You have exported a \`config\` field in route "/" that Next.js can't recognize, so it will be ignored`
),
})
})
})

View file

@ -0,0 +1,7 @@
export default function Page() {
return <p>hello world</p>
}
export const config = {
runtime: `something-${'real' + 1 + 'y odd'}`,
}