-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Server build: add webpack plugin that identifies and ships node_modules used by server #45774
Changes from all commits
6f6ddaa
d759b04
1394868
7434af3
a404b13
ccb58f3
577a8f2
018b30e
ac9000f
9b57b03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
const fs = require( 'fs' ); | ||
const rcopy = require( 'recursive-copy' ); | ||
const mkdirp = require( 'mkdirp' ); | ||
const path = require( 'path' ); | ||
const yargs = require( 'yargs' ); | ||
|
||
const args = yargs | ||
.usage( 'Usage: $0' ) | ||
.default( 'list', 'build/modules.json' ) | ||
.default( 'dest', 'build' ) | ||
.boolean( 'debug' ).argv; | ||
|
||
function debug( message ) { | ||
args.debug && console.log( message ); | ||
} | ||
|
||
try { | ||
debug( 'reading modules from ' + args.list ); | ||
const externalModules = JSON.parse( fs.readFileSync( args.list, 'utf8' ) ); | ||
debug( 'bundle directly requests ' + externalModules.length + ' packages' ); | ||
const shippingPkgList = processPackages( externalModules ); | ||
shipDependencies( shippingPkgList ); | ||
} catch ( err ) { | ||
console.error( err ); | ||
process.exit( 1 ); | ||
} | ||
|
||
function processPackages( pkgList ) { | ||
const context = { | ||
pkgList: [], | ||
visitedFolders: new Set(), | ||
folderStack: [ '.' ], | ||
requiredBy: 'the bundle', | ||
depth: 0, | ||
}; | ||
|
||
for ( const pkgName of pkgList ) { | ||
processPackage( pkgName, context ); | ||
} | ||
|
||
return context.pkgList; | ||
} | ||
|
||
function processPackage( pkgName, context ) { | ||
const { pkgList, folderStack, visitedFolders, requiredBy, depth } = context; | ||
|
||
const pkgFolder = folderStack[ 0 ] + '/node_modules/' + pkgName; | ||
|
||
// skip if the folder was already visited | ||
if ( visitedFolders.has( pkgFolder ) ) { | ||
return; | ||
} | ||
|
||
// mark the folder as visited | ||
visitedFolders.add( pkgFolder ); | ||
|
||
// Verify that the package resolves to a directory in `node_modules/`, and that | ||
// it is not a symlink to a monorepo packages. Such packages couldn't be shipped and | ||
// must be bundled. | ||
const pkgRealpath = path.relative( '.', fs.realpathSync( pkgFolder ) ); | ||
if ( ! pkgRealpath.startsWith( 'node_modules/' ) ) { | ||
throw new Error( | ||
'package ' + pkgName + ' resolves outside node_modules/, likely a symlink: ' + pkgRealpath | ||
); | ||
} | ||
|
||
// If the package is in the top-level folder, add it to the list of packages to ship. | ||
// Subpackages are already shipped together with the parent. | ||
if ( folderStack.length === 1 ) { | ||
const indent = ' '.repeat( depth ); | ||
debug( indent + 'shipping ' + pkgName + ': required by ' + requiredBy ); | ||
pkgList.push( pkgName ); | ||
} | ||
|
||
// read package's package.json | ||
const pkgJson = JSON.parse( fs.readFileSync( pkgFolder + '/package.json' ) ); | ||
|
||
// collect dependencies from various fields | ||
const depFields = [ 'dependencies', 'peerDependencies', 'optionalDependencies' ]; | ||
const pkgDeps = depFields.flatMap( ( type ) => Object.keys( pkgJson[ type ] || {} ) ); | ||
|
||
// bail out if package has no dependencies | ||
if ( ! pkgDeps.length ) { | ||
return; | ||
} | ||
|
||
// unshift the package's folder to the folder stack | ||
const subFolderStack = [ pkgFolder, ...folderStack ]; | ||
|
||
// iterate its dependencies | ||
for ( const depName of pkgDeps ) { | ||
// Find the dependency in node_modules tree, starting with the package's `node_modules/` | ||
// subdirectory and going up the directory tree to the root. | ||
const foundFolderIdx = subFolderStack.findIndex( ( folder ) => | ||
fs.existsSync( folder + '/node_modules/' + depName ) | ||
); | ||
|
||
if ( foundFolderIdx === -1 ) { | ||
throw new Error( 'package not found: ' + depName + ', dependency of ' + pkgFolder ); | ||
} | ||
|
||
// add the dependency to shipping list if eligible and recursively collect its dependencies | ||
const subContext = { | ||
...context, | ||
folderStack: subFolderStack.slice( foundFolderIdx ), | ||
requiredBy: pkgName, | ||
depth: depth + 1, | ||
}; | ||
processPackage( depName, subContext ); | ||
} | ||
} | ||
|
||
function shipDependencies( pkgList ) { | ||
const destDir = path.join( args.dest, 'node_modules' ); | ||
|
||
debug( 'copying ' + pkgList.length + ' packages to ' + destDir ); | ||
|
||
mkdirp.sync( destDir ); | ||
for ( const pkgName of pkgList ) { | ||
rcopy( path.join( 'node_modules', pkgName ), path.join( destDir, pkgName ), { | ||
overwrite: true, | ||
} ); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
/** | ||
* External Dependencies | ||
*/ | ||
const { builtinModules } = require( 'module' ); | ||
|
||
function getModule( request ) { | ||
const parts = request.split( '/' ); | ||
if ( parts[ 0 ].startsWith( '@' ) ) { | ||
return parts[ 0 ] + '/' + parts[ 1 ]; | ||
} | ||
return parts[ 0 ]; | ||
} | ||
|
||
module.exports = class ExternalModulesWriter { | ||
constructor( options ) { | ||
this.options = { | ||
path: './build', | ||
filename: 'modules.json', | ||
...options, | ||
}; | ||
} | ||
|
||
apply( compiler ) { | ||
compiler.hooks.emit.tapAsync( 'ExternalModulesWriter', ( compilation, callback ) => { | ||
const externalModules = new Set(); | ||
|
||
for ( const module of compilation.modules ) { | ||
if ( ! module.external ) { | ||
continue; | ||
} | ||
|
||
const requestModule = getModule( module.userRequest ); | ||
|
||
// native Node.js module, not in node_modules | ||
if ( builtinModules.includes( requestModule ) ) { | ||
continue; | ||
} | ||
|
||
// loading local file by relative path, not in node_modules | ||
if ( requestModule.startsWith( './' ) || requestModule.startsWith( '../' ) ) { | ||
continue; | ||
} | ||
|
||
externalModules.add( requestModule ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of generating a file with the module names that we resolve manually using our own implementation later, can we change this plugin to get the list of resolved modules by webpack? That way we guarantee they resolve to the same file. If that's not possible (webpack is ignoring anything with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. webpack never really resolves these external modules. When it sees a request that matches the externals criteria, e.g.,
The knowledge about how would webpack resolve the request doesn't look very relevant: webpack never does that resolution, and the bundle contains simple It therefore seems right that the list contains a simple Also, if I tried to resolve |
||
} | ||
|
||
const json = JSON.stringify( Array.from( externalModules ), null, 2 ); | ||
const { mkdirp, writeFile, join } = compiler.outputFileSystem; | ||
const { path, filename } = this.options; | ||
mkdirp( path, ( err ) => { | ||
if ( err ) { | ||
return callback( err ); | ||
} | ||
writeFile( join( path, filename ), json, 'utf8', callback ); | ||
} ); | ||
} ); | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -75,9 +75,11 @@ | |
"build-desktop:app": "cd desktop && yarn run build", | ||
"test-desktop:e2e": "node desktop/e2e/run.js", | ||
"build-docker": "node bin/build-docker.js", | ||
"build-static": "npx ncp static public", | ||
"build-static": "npx ncp static public && npx ncp client/lib/service-worker public --filter=\"service-worker(/service-worker\\.js)?$\"", | ||
"prebuild-server": "mkdirp build", | ||
"build-server": "BROWSERSLIST_ENV=server webpack --config client/webpack.config.node.js --display errors-only", | ||
"build-server:copy-modules": "node bin/copy-production-modules.js", | ||
"postbuild-server": "node -e \"process.env.CALYPSO_ENV === 'production' && process.exit(1)\" || yarn run build-server:copy-modules", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we do this in other envs as well like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The During build, the values used in practice are We never use |
||
"build-server-stats": "NODE_ENV=production CALYPSO_ENV=production EMIT_STATS=true yarn run build-server", | ||
"build-client": "yarn run build-client-evergreen", | ||
"build-client-do": "node ${NODE_ARGS:---max-old-space-size=8192} ./node_modules/webpack/bin/webpack.js --config client/webpack.config.js --display errors-only", | ||
|
@@ -361,6 +363,7 @@ | |
"react-dom": "^16.12.0", | ||
"react-test-renderer": "^16.12.0", | ||
"readline-sync": "^1.4.10", | ||
"recursive-copy": "^2.0.10", | ||
"replace": "^1.1.5", | ||
"request": "^2.88.2", | ||
"rimraf": "^3.0.0", | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if server imports something that is in
client/node_modules
, or inclient/server/node_modules
? Are those bundled?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The server bundle is in
build/server.js
, both before and after this patch, so it doesn't see modules inclient/
. Its resolution paths arebuild/node_modules
,node_modules
and parents.Modules in
client/
are visible only to webpack at build-time, when the resolution context are the source directories. Once a module is externalized, i.e., translated to a rawrequire( 'foo' )
call executed by Node.js at runtime, this context is lost.This could be a problem, because there's no guarantee that all externalized modules are always hoisted to the top
node_modules
directory. But this particular PR doesn't make it better or worse.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed this is an existing (and pretty bad) problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's one more little detail involved here: the
webpack-node-externals
plugin builds the list of externals by listing the rootnode_modules
directory. If the sources request afoo
package and the package is inclient/node_modules/foo
and not innode_modules/foo
, the plugin won't identifyfoo
as external and will let webpack bundle it.