Skip to content

Commit

Permalink
Detect per page runtime config for functions manifest (vercel#33945)
Browse files Browse the repository at this point in the history
## Feature

Follow up for vercel#33770

* When page config specify runtime is "nodejs", remove runtime option in functions manifest;
* If user enable `concurrentFeatures` and filesystem api, use `runtime: "web"` for those pages;


- [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
  • Loading branch information
huozhi authored and natew committed Feb 16, 2022
1 parent 4eea0d8 commit c7be0c4
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 26 deletions.
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({
dev,
pagesDir,
webServerRuntime,
pageExtensions: config.pageExtensions,
}),
isServer && new NextJsSsrImportPlugin(),
!isServer &&
new BuildManifestPlugin({
Expand Down
95 changes: 91 additions & 4 deletions packages/next/build/webpack/plugins/functions-manifest-plugin.ts
@@ -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,
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')
expect(files.every((f) => f.startsWith('server/'))).toBe(true)
})

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

0 comments on commit c7be0c4

Please sign in to comment.