diff --git a/packages/cli/src/lib/walker-package-ranger.js b/packages/cli/src/lib/walker-package-ranger.js new file mode 100644 index 000000000..320ba5d6f --- /dev/null +++ b/packages/cli/src/lib/walker-package-ranger.js @@ -0,0 +1,235 @@ +/* eslint-disable max-depth,complexity */ +import * as acorn from 'acorn'; +import fs from 'fs'; +import { getNodeModulesLocationForPackage } from './node-modules-utils.js'; +import path from 'path'; +import * as walk from 'acorn-walk'; + +const importMap = {}; + +const updateImportMap = (entry, entryPath) => { + + if (path.extname(entryPath) === '') { + entryPath = `${entryPath}.js`; + } + + // handle WIn v Unix-style path separators and force to / + importMap[entry.replace(/\\/g, '/')] = entryPath.replace(/\\/g, '/'); +}; + +// handle ESM paths that have varying levels of nesting, e.g. export * from '../../something.js' +// https://github.com/ProjectEvergreen/greenwood/issues/820 +async function resolveRelativeSpecifier(specifier, modulePath, dependency) { + const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency); + + // handle WIn v Unix-style path separators and force to / + return `${dependency}${path.join(path.dirname(modulePath), specifier).replace(/\\/g, '/').replace(absoluteNodeModulesLocation.replace(/\\/g, '/', ''), '')}`; +} + +async function getPackageEntryPath(packageJson) { + let entry = packageJson.exports + ? Object.keys(packageJson.exports) // first favor export maps first + : packageJson.module // next favor ESM entry points + ? packageJson.module + : packageJson.main && packageJson.main !== '' // then favor main + ? packageJson.main + : 'index.js'; // lastly, fallback to index.js + + // use .mjs version if it exists, for packages like redux + if (!Array.isArray(entry) && fs.existsSync(`${await getNodeModulesLocationForPackage(packageJson.name)}/${entry.replace('.js', '.mjs')}`)) { + entry = entry.replace('.js', '.mjs'); + } + + return entry; +} + +async function walkModule(modulePath, dependency) { + const moduleContents = fs.readFileSync(modulePath, 'utf-8'); + + walk.simple(acorn.parse(moduleContents, { + ecmaVersion: '2020', + sourceType: 'module' + }), { + async ImportDeclaration(node) { + let { value: sourceValue } = node.source; + const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency); + const isBarePath = sourceValue.indexOf('http') !== 0 && sourceValue.charAt(0) !== '.' && sourceValue.charAt(0) !== path.sep; + const hasExtension = path.extname(sourceValue) !== ''; + + if (isBarePath && !hasExtension) { + if (!importMap[sourceValue]) { + updateImportMap(sourceValue, `/node_modules/${sourceValue}`); + } + + await walkPackageJson(path.join(absoluteNodeModulesLocation, 'package.json')); + } else if (isBarePath) { + updateImportMap(sourceValue, `/node_modules/${sourceValue}`); + } else { + // walk this module for all its dependencies + sourceValue = !hasExtension + ? `${sourceValue}.js` + : sourceValue; + + if (fs.existsSync(path.join(absoluteNodeModulesLocation, sourceValue))) { + const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`; + await walkModule(path.join(absoluteNodeModulesLocation, sourceValue), dependency); + + updateImportMap(path.join(dependency, sourceValue), entry); + } + } + }, + async ExportNamedDeclaration(node) { + const sourceValue = node && node.source ? node.source.value : ''; + + if (sourceValue !== '' && sourceValue.indexOf('http') !== 0) { + // handle relative specifier + if (sourceValue.indexOf('.') === 0) { + const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`; + + updateImportMap(path.join(dependency, sourceValue), entry); + } else { + // handle bare specifier + updateImportMap(sourceValue, `/node_modules/${sourceValue}`); + } + } + }, + async ExportAllDeclaration(node) { + const sourceValue = node && node.source ? node.source.value : ''; + + if (sourceValue !== '' && sourceValue.indexOf('http') !== 0) { + if (sourceValue.indexOf('.') === 0) { + const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`; + + updateImportMap(path.join(dependency, sourceValue), entry); + } else { + updateImportMap(sourceValue, `/node_modules/${sourceValue}`); + } + } + } + }); +} + +async function walkPackageJson(packageJson = {}) { + // while walking a package.json we need to find its entry point, e.g. index.js + // and then walk that for import / export statements + // and walk its package.json for its dependencies + + for (const dependency of Object.keys(packageJson.dependencies || {})) { + const dependencyPackageRootPath = path.join(process.cwd(), 'node_modules', dependency); + const dependencyPackageJsonPath = path.join(dependencyPackageRootPath, 'package.json'); + const dependencyPackageJson = JSON.parse(fs.readFileSync(dependencyPackageJsonPath, 'utf-8')); + const entry = await getPackageEntryPath(dependencyPackageJson); + const isJavascriptPackage = Array.isArray(entry) || typeof entry === 'string' && entry.endsWith('.js') || entry.endsWith('.mjs'); + + if (isJavascriptPackage) { + const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency); + + // https://nodejs.org/api/packages.html#packages_determining_module_system + if (Array.isArray(entry)) { + // we have an exportMap + const exportMap = entry; + + for (const entry of exportMap) { + const exportMapEntry = dependencyPackageJson.exports[entry]; + let packageExport; + + if (Array.isArray(exportMapEntry)) { + let fallbackPath; + let esmPath; + + exportMapEntry.forEach((mapItem) => { + switch (typeof mapItem) { + + case 'string': + fallbackPath = mapItem; + break; + case 'object': + const entryTypes = Object.keys(mapItem); + + if (entryTypes.import) { + esmPath = entryTypes.import; + } else if (entryTypes.require) { + console.error('The package you are importing needs commonjs support. Please use our commonjs plugin to fix this error.'); + fallbackPath = entryTypes.require; + } else if (entryTypes.default) { + console.warn('The package you are requiring may need commonjs support. If this module is not working for you, consider adding our commonjs plugin.'); + fallbackPath = entryTypes.default; + } + break; + default: + console.warn(`Sorry, we were unable to detect the module type for ${mapItem} :(. please consider opening an issue to let us know about your use case.`); + break; + + } + }); + + packageExport = esmPath + ? esmPath + : fallbackPath; + } else if (exportMapEntry.import || exportMapEntry.default) { + packageExport = exportMapEntry.import + ? exportMapEntry.import + : exportMapEntry.default; + + // use the dependency itself as an entry in the importMap + if (entry === '.') { + updateImportMap(dependency, `/node_modules/${path.join(dependency, packageExport)}`); + } + } else if (exportMapEntry.endsWith && (exportMapEntry.endsWith('.js') || exportMapEntry.endsWith('.mjs')) && exportMapEntry.indexOf('*') < 0) { + // is probably a file, so _not_ an export array, package.json, or wildcard export + packageExport = exportMapEntry; + } + + if (packageExport) { + const packageExportLocation = path.resolve(absoluteNodeModulesLocation, packageExport); + + if (packageExport.endsWith('js')) { + updateImportMap(path.join(dependency, entry), `/node_modules/${path.join(dependency, packageExport)}`); + } else if (fs.lstatSync(packageExportLocation).isDirectory()) { + fs.readdirSync(packageExportLocation) + .filter(file => file.endsWith('.js') || file.endsWith('.mjs')) + .forEach((file) => { + updateImportMap(path.join(dependency, packageExport, file), `/node_modules/${path.join(dependency, packageExport, file)}`); + }); + } else { + console.warn('Warning, not able to handle export', path.join(dependency, packageExport)); + } + } + } + + await walkPackageJson(dependencyPackageJson); + } else { + const packageEntryPointPath = path.join(absoluteNodeModulesLocation, entry); + + // sometimes a main file is actually just an empty string... :/ + if (fs.existsSync(packageEntryPointPath)) { + updateImportMap(dependency, `/node_modules/${path.join(dependency, entry)}`); + + await walkModule(packageEntryPointPath, dependency); + await walkPackageJson(dependencyPackageJson); + } + } + } + } + + return importMap; +} + +function mergeImportMap(html = '', map = {}) { + // es-modules-shims breaks on dangling commas in an importMap :/ + const danglingComma = html.indexOf('"imports": {}') > 0 ? '' : ','; + const importMap = JSON.stringify(map).replace('}', '').replace('{', ''); + + const merged = html.replace('"imports": {', ` + "imports": { + ${importMap}${danglingComma} + `); + + return merged; +} + +export { + mergeImportMap, + walkPackageJson, + walkModule +}; \ No newline at end of file diff --git a/packages/cli/src/plugins/resource/plugin-node-modules.js b/packages/cli/src/plugins/resource/plugin-node-modules.js index b6b2622bf..db04f5d85 100644 --- a/packages/cli/src/plugins/resource/plugin-node-modules.js +++ b/packages/cli/src/plugins/resource/plugin-node-modules.js @@ -1,225 +1,17 @@ -/* eslint-disable max-depth,complexity */ /* * * Detects and fully resolves requests to node_modules and handles creating an importMap. * */ -import * as acorn from 'acorn'; import fs from 'fs'; import path from 'path'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; import { getNodeModulesLocationForPackage, getPackageNameFromUrl } from '../../lib/node-modules-utils.js'; import { ResourceInterface } from '../../lib/resource-interface.js'; -import * as walk from 'acorn-walk'; +import { walkPackageJson } from '../../lib/walker-package-ranger.js'; -const importMap = {}; - -const updateImportMap = (entry, entryPath) => { - - if (path.extname(entryPath) === '') { - entryPath = `${entryPath}.js`; - } - - // handle WIn v Unix-style path separators and force to / - importMap[entry.replace(/\\/g, '/')] = entryPath.replace(/\\/g, '/'); -}; - -// handle ESM paths that have varying levels of nesting, e.g. export * from '../../something.js' -// https://github.com/ProjectEvergreen/greenwood/issues/820 -async function resolveRelativeSpecifier(specifier, modulePath, dependency) { - const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency); - - // handle WIn v Unix-style path separators and force to / - return `${dependency}${path.join(path.dirname(modulePath), specifier).replace(/\\/g, '/').replace(absoluteNodeModulesLocation.replace(/\\/g, '/', ''), '')}`; -} - -const getPackageEntryPath = async (packageJson) => { - let entry = packageJson.exports - ? Object.keys(packageJson.exports) // first favor export maps first - : packageJson.module // next favor ESM entry points - ? packageJson.module - : packageJson.main && packageJson.main !== '' // then favor main - ? packageJson.main - : 'index.js'; // lastly, fallback to index.js - - // use .mjs version if it exists, for packages like redux - if (!Array.isArray(entry) && fs.existsSync(`${await getNodeModulesLocationForPackage(packageJson.name)}/${entry.replace('.js', '.mjs')}`)) { - entry = entry.replace('.js', '.mjs'); - } - - return entry; -}; - -const walkModule = async (modulePath, dependency) => { - const moduleContents = fs.readFileSync(modulePath, 'utf-8'); - - walk.simple(acorn.parse(moduleContents, { - ecmaVersion: '2020', - sourceType: 'module' - }), { - async ImportDeclaration(node) { - let { value: sourceValue } = node.source; - const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency); - const isBarePath = sourceValue.indexOf('http') !== 0 && sourceValue.charAt(0) !== '.' && sourceValue.charAt(0) !== path.sep; - const hasExtension = path.extname(sourceValue) !== ''; - - if (isBarePath && !hasExtension) { - if (!importMap[sourceValue]) { - updateImportMap(sourceValue, `/node_modules/${sourceValue}`); - } - - await walkPackageJson(path.join(absoluteNodeModulesLocation, 'package.json')); - } else if (isBarePath) { - updateImportMap(sourceValue, `/node_modules/${sourceValue}`); - } else { - // walk this module for all its dependencies - sourceValue = !hasExtension - ? `${sourceValue}.js` - : sourceValue; - - if (fs.existsSync(path.join(absoluteNodeModulesLocation, sourceValue))) { - const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`; - await walkModule(path.join(absoluteNodeModulesLocation, sourceValue), dependency); - - updateImportMap(path.join(dependency, sourceValue), entry); - } - } - }, - async ExportNamedDeclaration(node) { - const sourceValue = node && node.source ? node.source.value : ''; - - if (sourceValue !== '' && sourceValue.indexOf('http') !== 0) { - // handle relative specifier - if (sourceValue.indexOf('.') === 0) { - const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`; - - updateImportMap(path.join(dependency, sourceValue), entry); - } else { - // handle bare specifier - updateImportMap(sourceValue, `/node_modules/${sourceValue}`); - } - } - }, - async ExportAllDeclaration(node) { - const sourceValue = node && node.source ? node.source.value : ''; - - if (sourceValue !== '' && sourceValue.indexOf('http') !== 0) { - if (sourceValue.indexOf('.') === 0) { - const entry = `/node_modules/${await resolveRelativeSpecifier(sourceValue, modulePath, dependency)}`; - - updateImportMap(path.join(dependency, sourceValue), entry); - } else { - updateImportMap(sourceValue, `/node_modules/${sourceValue}`); - } - } - } - }); -}; - -const walkPackageJson = async (packageJson = {}) => { - // while walking a package.json we need to find its entry point, e.g. index.js - // and then walk that for import / export statements - // and walk its package.json for its dependencies - - for (const dependency of Object.keys(packageJson.dependencies || {})) { - const dependencyPackageRootPath = path.join(process.cwd(), 'node_modules', dependency); - const dependencyPackageJsonPath = path.join(dependencyPackageRootPath, 'package.json'); - const dependencyPackageJson = JSON.parse(fs.readFileSync(dependencyPackageJsonPath, 'utf-8')); - const entry = await getPackageEntryPath(dependencyPackageJson); - const isJavascriptPackage = Array.isArray(entry) || typeof entry === 'string' && entry.endsWith('.js') || entry.endsWith('.mjs'); - - if (isJavascriptPackage) { - const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(dependency); - - // https://nodejs.org/api/packages.html#packages_determining_module_system - if (Array.isArray(entry)) { - // we have an exportMap - const exportMap = entry; - - for (const entry of exportMap) { - const exportMapEntry = dependencyPackageJson.exports[entry]; - let packageExport; - - if (Array.isArray(exportMapEntry)) { - let fallbackPath; - let esmPath; - - exportMapEntry.forEach((mapItem) => { - switch (typeof mapItem) { - - case 'string': - fallbackPath = mapItem; - break; - case 'object': - const entryTypes = Object.keys(mapItem); - - if (entryTypes.import) { - esmPath = entryTypes.import; - } else if (entryTypes.require) { - console.error('The package you are importing needs commonjs support. Please use our commonjs plugin to fix this error.'); - fallbackPath = entryTypes.require; - } else if (entryTypes.default) { - console.warn('The package you are requiring may need commonjs support. If this module is not working for you, consider adding our commonjs plugin.'); - fallbackPath = entryTypes.default; - } - break; - default: - console.warn(`Sorry, we were unable to detect the module type for ${mapItem} :(. please consider opening an issue to let us know about your use case.`); - break; - - } - }); - - packageExport = esmPath - ? esmPath - : fallbackPath; - } else if (exportMapEntry.import || exportMapEntry.default) { - packageExport = exportMapEntry.import - ? exportMapEntry.import - : exportMapEntry.default; - - // use the dependency itself as an entry in the importMap - if (entry === '.') { - updateImportMap(dependency, `/node_modules/${path.join(dependency, packageExport)}`); - } - } else if (exportMapEntry.endsWith && (exportMapEntry.endsWith('.js') || exportMapEntry.endsWith('.mjs')) && exportMapEntry.indexOf('*') < 0) { - // is probably a file, so _not_ an export array, package.json, or wildcard export - packageExport = exportMapEntry; - } - - if (packageExport) { - const packageExportLocation = path.resolve(absoluteNodeModulesLocation, packageExport); - - if (packageExport.endsWith('js')) { - updateImportMap(path.join(dependency, entry), `/node_modules/${path.join(dependency, packageExport)}`); - } else if (fs.lstatSync(packageExportLocation).isDirectory()) { - fs.readdirSync(packageExportLocation) - .filter(file => file.endsWith('.js') || file.endsWith('.mjs')) - .forEach((file) => { - updateImportMap(path.join(dependency, packageExport, file), `/node_modules/${path.join(dependency, packageExport, file)}`); - }); - } else { - console.warn('Warning, not able to handle export', path.join(dependency, packageExport)); - } - } - } - - await walkPackageJson(dependencyPackageJson); - } else { - const packageEntryPointPath = path.join(absoluteNodeModulesLocation, entry); - - // sometimes a main file is actually just an empty string... :/ - if (fs.existsSync(packageEntryPointPath)) { - updateImportMap(dependency, `/node_modules/${path.join(dependency, entry)}`); - - await walkModule(packageEntryPointPath, dependency); - await walkPackageJson(dependencyPackageJson); - } - } - } - } -}; +let importMap; class NodeModulesResource extends ResourceInterface { constructor(compilation, options) { @@ -302,13 +94,14 @@ class NodeModulesResource extends ResourceInterface { ? JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')) : {}; - // walk the project's pacakge.json for all its direct dependencies + // if there are dependencies and we haven't generated the importMap already + // walk the project's package.json for all its direct dependencies // for each entry found in dependencies, find its entry point // then walk its entry point (e.g. index.js) for imports / exports to add to the importMap // and then walk its package.json for transitive dependencies and all those import / exports - if (Object.keys(importMap).length === 0) { - await walkPackageJson(userPackageJson); - } + importMap = !importMap && Object.keys(userPackageJson.dependencies || []).length > 0 + ? await walkPackageJson(userPackageJson) + : importMap || {}; // apply import map and shim for users newContents = newContents.replace('', ` diff --git a/packages/plugin-graphql/src/index.js b/packages/plugin-graphql/src/index.js index 122d64854..1a1724849 100644 --- a/packages/plugin-graphql/src/index.js +++ b/packages/plugin-graphql/src/index.js @@ -1,10 +1,21 @@ import fs from 'fs'; import { graphqlServer } from './core/server.js'; +import { mergeImportMap } from '@greenwood/cli/src/lib/walker-package-ranger.js'; import path from 'path'; import { ResourceInterface } from '@greenwood/cli/src/lib/resource-interface.js'; import { ServerInterface } from '@greenwood/cli/src/lib/server-interface.js'; import rollupPluginAlias from '@rollup/plugin-alias'; +const importMap = { + '@greenwood/cli/src/lib/hashing-utils.js': '/node_modules/@greenwood/cli/src/lib/hashing-utils.js', + '@greenwood/plugin-graphql/core/client': '/node_modules/@greenwood/plugin-graphql/src/core/client.js', + '@greenwood/plugin-graphql/core/common': '/node_modules/@greenwood/plugin-graphql/src/core/common.js', + '@greenwood/plugin-graphql/queries/children': '/node_modules/@greenwood/plugin-graphql/src/queries/children.gql', + '@greenwood/plugin-graphql/queries/config': '/node_modules/@greenwood/plugin-graphql/src/queries/config.gql', + '@greenwood/plugin-graphql/queries/graph': '/node_modules/@greenwood/plugin-graphql/src/queries/graph.gql', + '@greenwood/plugin-graphql/queries/menu': '/node_modules/@greenwood/plugin-graphql/src/queries/menu.gql' +}; + class GraphQLResource extends ResourceInterface { constructor(compilation, options = {}) { super(compilation, options); @@ -37,22 +48,9 @@ class GraphQLResource extends ResourceInterface { async intercept(url, body) { return new Promise(async (resolve, reject) => { try { - // es-modules-shims breaks on dangling commas in an importMap :/ - const danglingComma = body.indexOf('"imports": {}') > 0 - ? '' - : ','; - const shimmedBody = body.replace('"imports": {', ` - "imports": { - "@greenwood/cli/src/lib/hashing-utils.js": "/node_modules/@greenwood/cli/src/lib/hashing-utils.js", - "@greenwood/plugin-graphql/core/client": "/node_modules/@greenwood/plugin-graphql/src/core/client.js", - "@greenwood/plugin-graphql/core/common": "/node_modules/@greenwood/plugin-graphql/src/core/common.js", - "@greenwood/plugin-graphql/queries/children": "/node_modules/@greenwood/plugin-graphql/src/queries/children.gql", - "@greenwood/plugin-graphql/queries/config": "/node_modules/@greenwood/plugin-graphql/src/queries/config.gql", - "@greenwood/plugin-graphql/queries/graph": "/node_modules/@greenwood/plugin-graphql/src/queries/graph.gql", - "@greenwood/plugin-graphql/queries/menu": "/node_modules/@greenwood/plugin-graphql/src/queries/menu.gql"${danglingComma} - `); + const newBody = mergeImportMap(body, importMap); - resolve({ body: shimmedBody }); + resolve({ body: newBody }); } catch (e) { reject(e); } @@ -105,18 +103,18 @@ const greenwoodPluginGraphQL = (options = {}) => { }, { type: 'rollup', name: 'plugin-graphql:rollup', - provider: () => [ - rollupPluginAlias({ - entries: [ - { find: '@greenwood/plugin-graphql/core/client', replacement: '@greenwood/plugin-graphql/src/core/client.js' }, - { find: '@greenwood/plugin-graphql/core/common', replacement: '@greenwood/plugin-graphql/src/core/common.js' }, - { find: '@greenwood/plugin-graphql/queries/menu', replacement: '@greenwood/plugin-graphql/src/queries/menu.gql' }, - { find: '@greenwood/plugin-graphql/queries/config', replacement: '@greenwood/plugin-graphql/src/queries/config.gql' }, - { find: '@greenwood/plugin-graphql/queries/children', replacement: '@greenwood/plugin-graphql/src/queries/children.gql' }, - { find: '@greenwood/plugin-graphql/queries/graph', replacement: '@greenwood/plugin-graphql/src/queries/graph.gql' } - ] - }) - ] + provider: () => { + const aliasEntries = Object.keys(importMap).map(key => { + return { + find: key, + replacement: importMap[key].replace('/node_modules/', '') + }; + }); + + return [ + rollupPluginAlias({ entries: aliasEntries }) + ]; + } }]; };