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

Add entrypoint tracing #25538

Merged
merged 41 commits into from Aug 16, 2021
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5ddd237
Add ncc'd nft
ijjk May 27, 2021
7645c51
Add initial page tracing plugin
ijjk May 27, 2021
2c8df87
Include dynamic chunks and add outputting traces
ijjk May 28, 2021
963a210
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk May 28, 2021
6d4bd43
add check
ijjk May 28, 2021
5b1ed01
check name
ijjk May 28, 2021
11d1c38
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk May 28, 2021
61fb021
remove todo and clean up ignores
ijjk May 28, 2021
03f1235
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jun 16, 2021
3b5c026
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jun 26, 2021
9376530
update compiled
ijjk Jun 26, 2021
20aafbc
Add initial test for traces
ijjk Jun 26, 2021
8b69270
Update test for webpack 4
ijjk Jun 26, 2021
7419cd7
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jul 7, 2021
3ab3cd9
apply suggestions from review
ijjk Jul 7, 2021
02cfd01
only create traces with webpack 5
ijjk Jul 7, 2021
21f1752
lint-fix
ijjk Jul 7, 2021
71ccfd2
normalize test on windows
ijjk Jul 7, 2021
3c1e604
normalize files before output
ijjk Jul 7, 2021
8ae2785
update test
ijjk Jul 7, 2021
b2a6b41
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jul 12, 2021
0dbf716
Update plugin and add more tests
ijjk Jul 12, 2021
620121f
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jul 12, 2021
1631552
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jul 21, 2021
e401004
Apply suggestions from code review
ijjk Jul 21, 2021
7cab94e
Merge branch 'add/nft' of github.com:ijjk/next.js into add/nft
ijjk Jul 21, 2021
a2fb21f
Update tests
ijjk Jul 21, 2021
010ee05
Use relative paths for traces
ijjk Jul 21, 2021
a0138e9
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jul 21, 2021
cbd1b3a
Merge branch 'canary' into add/nft
ijjk Jul 21, 2021
4829836
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Jul 21, 2021
0aa38fd
Merge branch 'add/nft' of github.com:ijjk/next.js into add/nft
ijjk Jul 21, 2021
98daa68
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Aug 6, 2021
55111a3
Add experimental flag and include/exclude config
ijjk Aug 6, 2021
1f1f6e3
ncc glob
ijjk Aug 6, 2021
076d001
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Aug 6, 2021
8bae9aa
update precompiled
ijjk Aug 6, 2021
b0133b8
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Aug 12, 2021
5cfee37
Merge remote-tracking branch 'upstream/canary' into add/nft
ijjk Aug 16, 2021
9823923
Add excludes to nft ignore
ijjk Aug 16, 2021
2997ee2
Merge branch 'canary' into add/nft
ijjk Aug 16, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/next/build/index.ts
Expand Up @@ -662,6 +662,8 @@ export default async function build(
const serverPropsPages = new Set<string>()
const additionalSsgPaths = new Map<string, Array<string>>()
const additionalSsgPathsEncoded = new Map<string, Array<string>>()
const pageTraceIncludes = new Map<string, Array<string>>()
const pageTraceExcludes = new Map<string, Array<string>>()
const pageInfos = new Map<string, PageInfo>()
const pagesManifest = JSON.parse(
await promises.readFile(manifestPath, 'utf8')
Expand Down Expand Up @@ -820,6 +822,11 @@ export default async function build(
)
})

if (config.experimental.nftTracing) {
pageTraceIncludes.set(page, workerResult.traceIncludes || [])
pageTraceExcludes.set(page, workerResult.traceExcludes || [])
}

if (
workerResult.isStatic === false &&
(workerResult.isHybridAmp || workerResult.isAmpOnly)
Expand Down Expand Up @@ -958,6 +965,63 @@ export default async function build(
)
}

if (config.experimental.nftTracing) {
const globOrig = require('next/dist/compiled/glob') as typeof import('next/dist/compiled/glob')
const glob = (pattern: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
globOrig(pattern, { cwd: dir }, (err, files) => {
if (err) {
return reject(err)
}
resolve(files)
})
})
}

for (const page of pageKeys) {
const includeGlobs = pageTraceIncludes.get(page)
const excludeGlobs = pageTraceExcludes.get(page)

if (!includeGlobs?.length && !excludeGlobs?.length) {
continue
}

const traceFile = path.join(
distDir,
'server/pages',
`${page}.js.nft.json`
)
const traceContent = JSON.parse(
await promises.readFile(traceFile, 'utf8')
)
let includes: string[] = []
let excludes: string[] = []

if (includeGlobs?.length) {
for (const includeGlob of includeGlobs) {
includes.push(...(await glob(includeGlob)))
}
}

if (excludeGlobs?.length) {
for (const excludeGlob of excludeGlobs) {
excludes.push(...(await glob(excludeGlob)))
}
}

const combined = new Set([...traceContent.files, ...includes])
excludes.forEach((file) => combined.delete(file))

await promises.writeFile(
traceFile,
JSON.stringify({
version: traceContent.version,
files: [...combined],
})
)
}
}

if (serverPropsPages.size > 0 || ssgPages.size > 0) {
// We update the routes manifest after the build with the
// data routes since we can't determine these until after build
Expand Down
4 changes: 4 additions & 0 deletions packages/next/build/utils.ts
Expand Up @@ -831,6 +831,8 @@ export async function isPageStatic(
encodedPrerenderRoutes?: string[]
prerenderFallback?: boolean | 'blocking'
isNextImageImported?: boolean
traceIncludes?: string[]
traceExcludes?: string[]
}> {
const isPageStaticSpan = trace('is-page-static-utils', parentId)
return isPageStaticSpan.traceAsyncFn(async () => {
Expand Down Expand Up @@ -936,6 +938,8 @@ export async function isPageStatic(
hasStaticProps,
hasServerProps,
isNextImageImported,
traceIncludes: config.unstable_includeFiles || [],
traceExcludes: config.unstable_excludeFiles || [],
}
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') return {}
Expand Down
7 changes: 7 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -38,6 +38,7 @@ import BuildStatsPlugin from './webpack/plugins/build-stats-plugin'
import ChunkNamesPlugin from './webpack/plugins/chunk-names-plugin'
import { JsConfigPathsPlugin } from './webpack/plugins/jsconfig-paths-plugin'
import { DropClientPage } from './webpack/plugins/next-drop-client-page-plugin'
import { TraceEntryPointsPlugin } from './webpack/plugins/next-trace-entrypoints-plugin'
import NextJsSsrImportPlugin from './webpack/plugins/nextjs-ssr-import'
import NextJsSSRModuleCachePlugin from './webpack/plugins/nextjs-ssr-module-cache'
import PagesManifestPlugin from './webpack/plugins/pages-manifest-plugin'
Expand Down Expand Up @@ -1245,6 +1246,12 @@ export default async function getBaseWebpackConfig(
pagesDir,
}),
!isServer && new DropClientPage(),
config.experimental.nftTracing &&
!isLikeServerless &&
isServer &&
!dev &&
isWebpack5 &&
new TraceEntryPointsPlugin({ appDir: dir }),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
Expand Down
253 changes: 253 additions & 0 deletions packages/next/build/webpack/plugins/next-trace-entrypoints-plugin.ts
@@ -0,0 +1,253 @@
import nodePath from 'path'
import { nodeFileTrace } from 'next/dist/compiled/@vercel/nft'
import {
webpack,
isWebpack5,
sources,
} from 'next/dist/compiled/webpack/webpack'
import { TRACE_OUTPUT_VERSION } from '../../../shared/lib/constants'

const PLUGIN_NAME = 'TraceEntryPointsPlugin'
const TRACE_IGNORES = [
'**/*/node_modules/react/**/*.development.js',
'**/*/node_modules/react-dom/**/*.development.js',
]

function getModuleFromDependency(
compilation: any,
dep: any
): webpack.Module & { resource?: string } {
if (isWebpack5) {
return compilation.moduleGraph.getModule(dep)
}

return dep.module
}

export class TraceEntryPointsPlugin implements webpack.Plugin {
private appDir: string
private entryTraces: Map<string, string[]>

constructor({ appDir }: { appDir: string }) {
this.appDir = appDir
this.entryTraces = new Map()
}

// Here we output all traced assets and webpack chunks to a
// ${page}.js.nft.json file
createTraceAssets(compilation: any, assets: any) {
const outputPath = compilation.outputOptions.path

for (const entrypoint of compilation.entrypoints.values()) {
const entryFiles = new Set<string>()

for (const chunk of entrypoint
.getEntrypointChunk()
.getAllReferencedChunks()) {
for (const file of chunk.files) {
entryFiles.add(nodePath.join(outputPath, file))
}
for (const file of chunk.auxiliaryFiles) {
entryFiles.add(nodePath.join(outputPath, file))
}
}
// don't include the entry itself in the trace
entryFiles.delete(
nodePath.join(
outputPath,
`${isWebpack5 ? '../' : ''}${entrypoint.name}.js`
)
)
const traceOutputName = `${isWebpack5 ? '../' : ''}${
entrypoint.name
}.js.nft.json`
const traceOutputPath = nodePath.join(outputPath, traceOutputName)

assets[traceOutputName] = new sources.RawSource(
JSON.stringify({
version: TRACE_OUTPUT_VERSION,
files: [...entryFiles, ...this.entryTraces.get(entrypoint.name)!].map(
(file) => {
return nodePath
.relative(traceOutputPath, file)
.replace(/\\/g, '/')
}
),
})
)
}
}

apply(compiler: webpack.Compiler) {
if (isWebpack5) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
// @ts-ignore TODO: Remove ignore when webpack 5 is stable
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
// @ts-ignore TODO: Remove ignore when webpack 5 is stable
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
(assets: any) => {
this.createTraceAssets(compilation, assets)
}
)
})
} else {
compiler.hooks.emit.tap(PLUGIN_NAME, (compilation: any) => {
this.createTraceAssets(compilation, compilation.assets)
})
}

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.finishModules.tapAsync(
PLUGIN_NAME,
async (_stats: any, callback: any) => {
// we create entry -> module maps so that we can
// look them up faster instead of having to iterate
// over the compilation modules list
const entryNameMap = new Map<string, string>()
const entryModMap = new Map<string, any>()

try {
const depModMap = new Map<string, any>()

compilation.entries.forEach((entry) => {
const name = entry.name || entry.options?.name

if (name?.startsWith('pages/') && entry.dependencies[0]) {
Copy link
Member

Choose a reason for hiding this comment

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

Here you should iterate over all entry.dependencies to handle cases like entry: { "pages/a": ["./pages/a.js", "./something-else.js"] }

const entryMod = getModuleFromDependency(
compilation,
entry.dependencies[0]
)

if (entryMod.resource) {
entryNameMap.set(entryMod.resource, name)
entryModMap.set(entryMod.resource, entryMod)
}
}
})

// TODO: investigate allowing non-sync fs calls in node-file-trace
// for better performance
const readFile = (path: string) => {
const mod = depModMap.get(path) || entryModMap.get(path)
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't cover all modules in the compilation. Only a subset.


// map the transpiled source when available to avoid
// parse errors in node-file-trace
const source = mod?.originalSource?.()

if (source) {
return source.buffer()
}

try {
return compilation.inputFileSystem.readFileSync(path)
Copy link
Member

Choose a reason for hiding this comment

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

Reading the files directly from fs is probably not the best idea.

A loader could have modified the source code after filesystem. Or it could be transpiled from a language nft doesn't understand.

There is a NormalModule.originalSource() function which gives you the source code for a module after loader processing. That's in a language webpack understands, so either javascript, or something else you can ignore. Best check Module.type for javascript/* to only process JS code.

Note that some modules might not end up at all in the output, so best use the list of chunks from createTraceAssets and grab all modules from there, de-duplicate them, analyse them with nft for more references, merge and cache the results per chunk and add them to the analysis data while iterating over the chunks.

Copy link
Member Author

Choose a reason for hiding this comment

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

It looks like we can't use the list of files from createTraceAssets during this hook since we need it to be immediately after transpiling with babel and compilation.entrypoints.values() seems to be empty at this stage. I added some additional tests and gathering the files from the entry's module dependencies seems to gather everything we need currently and allows us to use the dependencies source if available as well.

} catch (e) {
if (e.code === 'ENOENT' || e.code === 'EISDIR') {
return null
}
throw e
}
}
const readlink = (path: string) => {
try {
return compilation.inputFileSystem.readlinkSync(path)
Copy link
Member

Choose a reason for hiding this comment

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

sync fs calls might have a performance influence...

Copy link
Member Author

Choose a reason for hiding this comment

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

It seems node-file-trace currently relies on sync fs calls, ideally we could get these from the webpack cache so that it's not actually hitting the filesystem for most of these calls

} catch (e) {
if (
e.code !== 'EINVAL' &&
e.code !== 'ENOENT' &&
e.code !== 'UNKNOWN'
) {
throw e
}
return null
}
}
const stat = (path: string) => {
try {
return compilation.inputFileSystem.statSync(path)
} catch (e) {
if (e.code === 'ENOENT') {
return null
}
throw e
}
}

const nftCache = {}
const entryPaths = Array.from(entryModMap.keys())

for (const entry of entryPaths) {
depModMap.clear()
const entryMod = entryModMap.get(entry)
// TODO: investigate caching, will require ensuring no traced
// files in the cache have changed, we could potentially hash
// all traced files and only leverage the cache if the hashes
// match
// const cachedTraces = entryMod.buildInfo?.cachedNextEntryTrace

// Use cached trace if available and trace version matches
// if (
// isWebpack5 &&
// cachedTraces &&
// cachedTraces.version === TRACE_OUTPUT_VERSION
// ) {
// this.entryTraces.set(
// entryNameMap.get(entry)!,
// cachedTraces.tracedDeps
// )
// continue
// }
const collectDependencies = (mod: any) => {
if (!mod || !mod.dependencies) return

for (const dep of mod.dependencies) {
const depMod = getModuleFromDependency(compilation, dep)

if (depMod?.resource && !depModMap.get(depMod.resource)) {
depModMap.set(depMod.resource, depMod)
collectDependencies(depMod)
}
}
}
collectDependencies(entryMod)

const toTrace: string[] = [entry, ...depModMap.keys()]

const root = nodePath.parse(process.cwd()).root
styfle marked this conversation as resolved.
Show resolved Hide resolved
const result = await nodeFileTrace(toTrace, {
base: root,
cache: nftCache,
processCwd: this.appDir,
readFile,
readlink,
stat,
ignore: TRACE_IGNORES,
Copy link
Member

Choose a reason for hiding this comment

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

Do we also need to ignore the user's input from unstable_excludeFiles here?

Copy link
Member Author

@ijjk ijjk Aug 16, 2021

Choose a reason for hiding this comment

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

I was currently applying this at the end of the build where unstable_includeFiles is applied in case an includeFile collided with an excludeFile we could apply it here too if it helps with performance

Copy link
Member

Choose a reason for hiding this comment

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

I suspect it will impact performance because you can stop tracing earlier so we don't look up deps of deps.

https://github.com/vercel/vercel/blob/18bec983aefbe2a77bd14eda6fca59ff7e956d8b/packages/node/src/index.ts#L204

Copy link
Member Author

Choose a reason for hiding this comment

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

Added this to the ignore config here 9823923

Copy link
Member Author

@ijjk ijjk Aug 16, 2021

Choose a reason for hiding this comment

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

Or actually it seems we can't pass this to the plugin since we need the built pages to gather the page configs 🤔 we might need to make this a next.config.js config to allow excluding while tracing

Copy link
Member

Choose a reason for hiding this comment

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

Oh I assumed thats what it was: a next.config.js config so users could exclude files

})

const tracedDeps: string[] = []

for (const file of result.fileList) {
if (result.reasons[file].type === 'initial') {
styfle marked this conversation as resolved.
Show resolved Hide resolved
continue
}
tracedDeps.push(nodePath.join(root, file))
}

// entryMod.buildInfo.cachedNextEntryTrace = {
// version: TRACE_OUTPUT_VERSION,
// tracedDeps,
// }
this.entryTraces.set(entryNameMap.get(entry)!, tracedDeps)
}

callback()
} catch (err) {
callback(err)
}
}
)
})
}
}
7 changes: 7 additions & 0 deletions packages/next/compiled/@vercel/nft/LICENSE
@@ -0,0 +1,7 @@
Copyright 2019 Vercel, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1 change: 1 addition & 0 deletions packages/next/compiled/@vercel/nft/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/next/compiled/@vercel/nft/package.json
@@ -0,0 +1 @@
{"name":"@vercel/nft","main":"index.js","license":"MIT"}