Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect per page runtime config for functions manifest #33945

Merged
merged 12 commits into from Feb 6, 2022
18 changes: 9 additions & 9 deletions packages/next/build/entries.ts
Expand Up @@ -25,6 +25,12 @@ export type PagesMapping = {
[page: string]: string
}

export function getPageFromPath(pagePath: string, extensions: string[]) {
let page = pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '')
page = page.replace(/\\/g, '/').replace(/\/index$/, '')
return page === '' ? '/' : page
}

export function createPagesMapping(
pagePaths: string[],
extensions: string[],
Expand All @@ -47,20 +53,14 @@ export function createPagesMapping(

const pages: PagesMapping = pagePaths.reduce(
(result: PagesMapping, pagePath): PagesMapping => {
let page = pagePath.replace(
new RegExp(`\\.+(${extensions.join('|')})$`),
''
)
if (hasServerComponents && /\.client$/.test(page)) {
const pageKey = getPageFromPath(pagePath, extensions)

if (hasServerComponents && /\.client$/.test(pageKey)) {
// Assume that if there's a Client Component, that there is
// a matching Server Component that will map to the page.
return result
}

page = page.replace(/\\/g, '/').replace(/\/index$/, '')

const pageKey = page === '' ? '/' : page

if (pageKey in result) {
warn(
`Duplicate page detected. ${chalk.cyan(
Expand Down
7 changes: 6 additions & 1 deletion packages/next/build/webpack-config.ts
Expand Up @@ -1443,7 +1443,12 @@ export default async function getBaseWebpackConfig(
new MiddlewarePlugin({ dev, webServerRuntime }),
process.env.ENABLE_FILE_SYSTEM_API === '1' &&
webServerRuntime &&
new FunctionsManifestPlugin({ dev, webServerRuntime }),
new FunctionsManifestPlugin({
huozhi marked this conversation as resolved.
Show resolved Hide resolved
dev,
pagesDir,
webServerRuntime,
pageExtensions: config.pageExtensions,
}),
isServer && new NextJsSsrImportPlugin(),
!isServer &&
new BuildManifestPlugin({
Expand Down
@@ -1,13 +1,16 @@
import { relative } from 'path'
import { sources, webpack5 } from 'next/dist/compiled/webpack/webpack'
import { collectAssets, getEntrypointInfo } from './middleware-plugin'
import { normalizePagePath } from '../../../server/normalize-page-path'
import { FUNCTIONS_MANIFEST } from '../../../shared/lib/constants'
import { getPageFromPath } from '../../entries'
import { collectAssets, getEntrypointInfo } from './middleware-plugin'

const PLUGIN_NAME = 'FunctionsManifestPlugin'
export interface FunctionsManifest {
version: 1
pages: {
[page: string]: {
runtime: string
runtime?: string
env: string[]
files: string[]
name: string
Expand All @@ -17,19 +20,33 @@ export interface FunctionsManifest {
}
}

function containsPath(outer: string, inner: string) {
const rel = relative(outer, inner)
return !rel.startsWith('../') && rel !== '..'
}
export default class FunctionsManifestPlugin {
dev: boolean
pagesDir: string
pageExtensions: string[]
webServerRuntime: boolean
pagesRuntime: Map<string, string>

constructor({
dev,
pagesDir,
pageExtensions,
webServerRuntime,
}: {
dev: boolean
pagesDir: string
pageExtensions: string[]
webServerRuntime: boolean
}) {
this.dev = dev
this.pagesDir = pagesDir
this.webServerRuntime = webServerRuntime
this.pageExtensions = pageExtensions
this.pagesRuntime = new Map()
}

createAssets(
Expand All @@ -45,8 +62,14 @@ export default class FunctionsManifestPlugin {

const infos = getEntrypointInfo(compilation, envPerRoute, webServerRuntime)
infos.forEach((info) => {
functionsManifest.pages[info.page] = {
runtime: 'web',
const { page } = info
// TODO: use global default runtime instead of 'web'
const pageRuntime = this.pagesRuntime.get(page)
const isWebRuntime =
pageRuntime === 'edge' || (this.webServerRuntime && !pageRuntime)
functionsManifest.pages[page] = {
// Not assign if it's nodejs runtime, project configured node version is used instead
...(isWebRuntime && { runtime: 'web' }),
...info,
}
})
Expand All @@ -59,6 +82,70 @@ export default class FunctionsManifestPlugin {
}

apply(compiler: webpack5.Compiler) {
const handler = (parser: webpack5.javascript.JavascriptParser) => {
parser.hooks.exportSpecifier.tap(
PLUGIN_NAME,
(statement: any, _identifierName: string, exportName: string) => {
const { resource } = parser.state.module
const isPagePath = containsPath(this.pagesDir, resource)
// Only parse exported config in pages
if (!isPagePath) {
return
}
const { declaration } = statement
if (exportName === 'config') {
const varDecl = declaration.declarations[0]
const { properties } = varDecl.init
const prop = properties.find((p: any) => p.key.name === 'runtime')
if (!prop) return
const runtime = prop.value.value
if (!['nodejs', 'edge'].includes(runtime))
throw new Error(
`The runtime option can only be 'nodejs' or 'edge'`
)

// @ts-ignore buildInfo exists on Module
parser.state.module.buildInfo.NEXT_runtime = runtime
}
}
)
}

compiler.hooks.compilation.tap(
PLUGIN_NAME,
(
compilation: webpack5.Compilation,
{ normalModuleFactory: factory }: any
) => {
factory.hooks.parser.for('javascript/auto').tap(PLUGIN_NAME, handler)
factory.hooks.parser.for('javascript/esm').tap(PLUGIN_NAME, handler)

compilation.hooks.seal.tap(PLUGIN_NAME, () => {
for (const entryData of compilation.entries.values()) {
for (const dependency of entryData.dependencies) {
// @ts-ignore TODO: webpack 5 types
const module = compilation.moduleGraph.getModule(dependency)
const outgoingConnections =
compilation.moduleGraph.getOutgoingConnectionsByModule(module)
if (!outgoingConnections) return
const entryModules = outgoingConnections.keys()
for (const mod of entryModules) {
const runtime = mod?.buildInfo?.NEXT_runtime
if (runtime) {
// @ts-ignore: TODO: webpack 5 types
const normalizedPagePath = normalizePagePath(mod.userRequest)
const pagePath = normalizedPagePath.replace(this.pagesDir, '')
const page = getPageFromPath(pagePath, this.pageExtensions)
this.pagesRuntime.set(page, runtime)
break
}
}
}
}
})
}
)

collectAssets(compiler, this.createAssets.bind(this), {
dev: this.dev,
pluginName: PLUGIN_NAME,
Expand Down
25 changes: 16 additions & 9 deletions packages/next/build/webpack/plugins/middleware-plugin.ts
Expand Up @@ -38,6 +38,17 @@ const middlewareManifest: MiddlewareManifest = {
version: 1,
}

function getPageFromEntrypointName(pagePath: string) {
const ssrEntryInfo = ssrEntries.get(pagePath)
const result = MIDDLEWARE_FULL_ROUTE_REGEX.exec(pagePath)
const page = result
? `/${result[1]}`
: ssrEntryInfo
? pagePath.slice('pages'.length).replace(/\/index$/, '') || '/'
: null
return page
}

export function getEntrypointInfo(
compilation: webpack5.Compilation,
envPerRoute: Map<string, string[]>,
Expand All @@ -47,19 +58,15 @@ export function getEntrypointInfo(
const infos = []
for (const entrypoint of entrypoints.values()) {
if (!entrypoint.name) continue
const result = MIDDLEWARE_FULL_ROUTE_REGEX.exec(entrypoint.name)

const ssrEntryInfo = ssrEntries.get(entrypoint.name)

if (ssrEntryInfo && !webServerRuntime) continue
if (!ssrEntryInfo && webServerRuntime) continue

const location = result
? `/${result[1]}`
: ssrEntryInfo
? entrypoint.name.slice('pages'.length).replace(/\/index$/, '') || '/'
: null
const page = getPageFromEntrypointName(entrypoint.name)

if (!location) {
if (!page) {
continue
}

Expand All @@ -82,8 +89,8 @@ export function getEntrypointInfo(
env: envPerRoute.get(entrypoint.name) || [],
files,
name: entrypoint.name,
page: location,
regexp: getMiddlewareRegex(location, !ssrEntryInfo).namedRegex!,
page,
regexp: getMiddlewareRegex(page, !ssrEntryInfo).namedRegex!,
})
}
return infos
Expand Down
@@ -1,3 +1,5 @@
export default function foo() {
return 'foo.client'
}

export const config = 'this is not page config'
Expand Up @@ -27,3 +27,9 @@ export function getServerSideProps({ req }) {
},
}
}

export const config = {
amp: false,
unstable_runtimeJS: false,
huozhi marked this conversation as resolved.
Show resolved Hide resolved
runtime: 'nodejs',
}
Expand Up @@ -221,6 +221,7 @@ describe('Functions manifest', () => {
'server',
'functions-manifest.json'
)
await fs.remove(join(appDir, '.next'))
expect(fs.existsSync(functionsManifestPath)).toBe(false)
})
it('should contain rsc paths in functions manifest', async () => {
Expand All @@ -230,15 +231,17 @@ describe('Functions manifest', () => {
'server',
'functions-manifest.json'
)
const content = JSON.parse(await fs.readFile(functionsManifestPath, 'utf8'))
const content = JSON.parse(fs.readFileSync(functionsManifestPath, 'utf8'))
const { pages } = content
const pageNames = Object.keys(pages)

const paths = ['/', '/next-api/link', '/routes/[dynamic]']
paths.forEach((path) => {
const { runtime, files } = pages[path]
expect(pageNames).toContain(path)
expect(pages[path].runtime).toBe('web')
expect(pages[path].files.every((f) => f.startsWith('server/'))).toBe(true)
// Runtime of page `/` is undefined since it's configured as nodejs.
expect(runtime).toBe(path === '/' ? undefined : 'web')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we write the nodejs value to the manifest to be explicit and ensure the runtime field is always set?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filesystem api currently use nodejs version, if leave empty it will use the project confiugured node version. I guess we can't leave it as "nodejs"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely agree on having it always filled up

expect(files.every((f) => f.startsWith('server/'))).toBe(true)
})

expect(content.version).toBe(1)
Expand Down