UNPKG

utquidem

Version:

The meta-framework suite designed from scratch for frontend-focused modern web development.

413 lines (355 loc) 12 kB
import { createHash } from 'crypto'; import path from 'path'; import { chalk, fs, signale as logger, Signale } from '@modern-js/utils'; import enhancedResolve from 'enhanced-resolve'; import { Plugin as ESBuildPlugin } from 'esbuild'; import type { Compiler } from '@modern-js/esmpack'; import type { IAppContext, NormalizedConfig } from '@modern-js/core'; import { UnbundleDependencies } from 'src'; import { WEB_MODULES_DIR, GLOBAL_CACHE_DIR_NAME, TEMP_MODULES_DIR, META_DATA_FILE_NAME, ESBUILD_RESOLVE_PLUGIN_NAME, } from '../constants'; import { findPackageJson, pathToUrl } from '../utils'; import { scanImports } from './scan-imports'; import { ModulesCache, normalizeSemverSpecifierVersion } from './modules-cache'; const resolve = enhancedResolve.create.sync({ conditionNames: ['import', 'module', 'development', 'browser'], mainFields: ['browser', 'module', 'main'], }); // init global local modules cache export const modulesCache = new ModulesCache(GLOBAL_CACHE_DIR_NAME); // local web_modules dir, bundled by esbuild let webModulesDir: string; // temp esm modules like node_modules let tempModulesDir: string; // const debug = createDebugger(`esm:local-optimize`); const activeLogger = new Signale({ interactive: true, scope: 'optimize-deps', }); export interface DepsMetadata { hash: string; enableBabelMacros: boolean; map: Record<string, string>; } export type ResolvedDepsMap = Map< string, { version: string; filePath: string | null } >; const resolvedDepsMap: ResolvedDepsMap = new Map(); const resolveDepVersion = ( dep: string, importer: string, virtualDependenciesMap: Record<string, string>, ): { version: string; filePath: string | null } => { const cached = resolvedDepsMap.get(`${dep}${importer}`); if (cached) { return cached; } let version = 'latest'; let filePath = null; if (!virtualDependenciesMap[dep]) { try { const resolved = resolve(importer, dep); if (resolved) { const pkg = findPackageJson(resolved); version = pkg ? JSON.parse(fs.readFileSync(pkg, 'utf8')).version : 'latest'; filePath = resolved; } } catch (err) { logger.error(`resolve ${dep} error.\n ${err as string}`); } } resolvedDepsMap.set(`${dep}${importer}`, { version, filePath, }); return { version, filePath, }; }; export async function optimizeDeps({ userConfig, appContext, dependencies, }: { userConfig: NormalizedConfig; appContext: IAppContext; dependencies: UnbundleDependencies; }) { const { defaultDeps: defaultDependencies, virtualDeps: virtualDependenciesMap, internalPackages, } = dependencies; const ignoreModuleCache = userConfig?.dev?.unbundle?.ignoreModuleCache || process.env.SKIP_DEPS_CACHE === 'true'; const clearPdnCache = userConfig?.dev?.unbundle?.clearPdnCache || process.env.CLEAN_CACHE === 'true'; const pdnHost = userConfig?.dev?.unbundle?.pdnHost || dependencies.defaultPdnHost; const timer = process.hrtime(); const { appDirectory } = appContext; webModulesDir = path.resolve(appDirectory, WEB_MODULES_DIR); tempModulesDir = path.resolve(appDirectory, TEMP_MODULES_DIR); const dataPath = path.join(webModulesDir, META_DATA_FILE_NAME); // should clean gloabl modules cache and local cache if (clearPdnCache) { const isProcessEnv = process.env.CLEAN_CACHE === 'true'; const configSource = isProcessEnv ? 'process.env.CLEAN_CACHE' : 'clearPdnCache'; logger.info(`${configSource} is true, clear pdn cache.`); modulesCache.clean(); fs.removeSync(webModulesDir); fs.removeSync(tempModulesDir); } const { deps, enableBabelMacros } = await scanImports( userConfig, appContext, defaultDependencies, virtualDependenciesMap, ); const data: DepsMetadata = { hash: getDepHash(appDirectory, deps), enableBabelMacros, map: {}, }; let prevData; try { prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); } catch (e) {} // hash is consistent, no need to re-bundle // should ignore optimize deps when SKIP_DEPS_CACHE is falsy if (prevData && prevData.hash === data.hash && !ignoreModuleCache) { logger.info('Skip dependencies pre-optimization...'); return; } if (ignoreModuleCache) { const isProcessEnv = process.env.SKIP_DEPS_CACHE === 'true'; const configSource = isProcessEnv ? 'process.env.SKIP_DEPS_CACHE' : 'ignoreModuleCache'; logger.info(`${configSource} is true, pre-optimize dependencies.`); } [webModulesDir, tempModulesDir].forEach(dir => { if (fs.existsSync(dir)) { fs.emptyDirSync(dir); } else { fs.ensureDirSync(dir); } }); activeLogger.await(`Start pre-optimizing node_modules...`); try { await compileDeps( appDirectory, deps, data, internalPackages, virtualDependenciesMap, pdnHost, ); } catch (err) { logger.error(`Pre optimize deps error: \n${err as string}`); // eslint-disable-next-line no-process-exit process.exit(1); } fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); const latency = process.hrtime(timer); activeLogger.success( `Pre-optimize dependencies in ${chalk.yellow( `${latency[0] * 1000 + Math.floor(latency[1] / 1e6)}`, )} ms.`, ); } export const compileDeps = async ( appDirectory: string, deps: Array<{ specifier: string; importer: string; forceCompile?: boolean }>, metaData: DepsMetadata, internalPackages: Record<string, string>, virtualDependenciesMap: Record<string, string>, pdnHost: string, ) => { const bundleResult = await await require('esbuild').build({ entryPoints: deps.map(dep => dep.specifier), bundle: true, splitting: true, chunkNames: 'chunks/[name]-[hash]', metafile: true, outdir: webModulesDir, format: 'esm', ignoreAnnotations: true, plugins: [ { name: ESBUILD_RESOLVE_PLUGIN_NAME, setup(build) { build.onResolve({ filter: /^/ }, async args => { const { kind, pluginData: { virtualImporter = appDirectory } = {}, } = args; let { path: specifier } = args; if ( ['import-statement', 'entry-point', 'dynamic-import'].includes( kind, ) ) { // @@ is alias that point to generated runtime export code // so we should mark it as external if (specifier.startsWith('@@/')) { return { path: specifier, external: true }; } if (specifier.startsWith('/esm/bv/')) { const { name } = normalizeSemverSpecifierVersion(specifier); specifier = name; } activeLogger.await(`${chalk.yellow(`compile ${specifier}...`)}`); const internalPackageEntryFile = internalPackages[specifier] && resolveDepVersion( internalPackages[specifier], appDirectory, virtualDependenciesMap, ).filePath; const internalPackageEntryDir = internalPackageEntryFile && path.dirname(internalPackageEntryFile); const importer = internalPackageEntryFile ? resolveDepVersion( specifier, internalPackageEntryFile, virtualDependenciesMap, ).filePath : virtualImporter; const { version, filePath } = resolveDepVersion( specifier, importer, virtualDependenciesMap, ); const cached = modulesCache.get(specifier, version); if (cached) { return { path: cached.file, namespace: ESBUILD_RESOLVE_PLUGIN_NAME, pluginData: { filePath }, }; } // download esm file from pdn if ( !deps.find(dep => dep.specifier === specifier)?.forceCompile ) { const remoteResult = await modulesCache.requestRemoteCache( specifier, version, virtualDependenciesMap, pdnHost, ); if (remoteResult) { return { path: remoteResult.file, namespace: ESBUILD_RESOLVE_PLUGIN_NAME, pluginData: { filePath }, }; } } // use esmpack transform monorepo packages to esm // TODO const compiler: Compiler = await require('@modern-js/esmpack').esmpack({ // should use internal package path as cwd for internal packages cwd: internalPackageEntryDir || appDirectory, outDir: tempModulesDir, env: { NODE_ENV: JSON.stringify('development') }, }); const result = await compiler.run({ specifier, importer: virtualImporter, }); if (result.rollupOutput?.output[0].fileName) { // ensure monorepo package is written to metadata const builtDep = deps.find(dep => dep.specifier === specifier); if (builtDep) { builtDep.forceCompile = true; } return { path: path.join( tempModulesDir, result.rollupOutput?.output[0].fileName, ), namespace: ESBUILD_RESOLVE_PLUGIN_NAME, pluginData: { filePath }, }; } } }); build.onLoad( { filter: /^/, namespace: ESBUILD_RESOLVE_PLUGIN_NAME }, async args => ({ contents: await fs.readFile(args.path), pluginData: { virtualImporter: args?.pluginData?.filePath }, }), ); }, } as ESBuildPlugin, ], }); const { outputs } = bundleResult.metafile; // match short length key first // eg: specifier is 'foo.js', but user import '@bar/node_modules/foo.js' and 'foo.js' const keys = Object.keys(outputs).sort((a, b) => a.length - b.length); deps.forEach(({ specifier, forceCompile }) => { const key = keys.find(key => { const { entryPoint } = outputs[key]; // local compile monorepo packages // eg: entryPoint: ../../packages/common-utils/dist/index.js if (forceCompile && entryPoint?.endsWith(`${specifier}.js`)) { return true; } if (entryPoint) { const parts = entryPoint.split('@'); parts.pop(); if ( parts?.length && pathToUrl( parts .join('@') .replace( `${ESBUILD_RESOLVE_PLUGIN_NAME}:${modulesCache.dir}${path.sep}`, '', ), ) === specifier ) { return true; } } return false; }); if (key) { metaData.map[specifier] = pathToUrl( path.relative( path.resolve(appDirectory, WEB_MODULES_DIR), path.resolve(appDirectory, key), ), ); } else { // TODO: should use lazy warning logger.warn(`Can't find ${specifier}.`); } }); }; export function getDepHash( appDirectory: string, deps: Array<{ specifier: string; importer: string }>, ): string { const content = JSON.stringify(deps.map(dep => dep.specifier)); return createHash('sha256').update(content).digest('hex').substr(0, 16); }