diff --git a/packages/compat/src/audit.ts b/packages/compat/src/audit.ts index b075fff59..4a51e922c 100644 --- a/packages/compat/src/audit.ts +++ b/packages/compat/src/audit.ts @@ -1,7 +1,6 @@ import { readFileSync, readJSONSync } from 'fs-extra'; import { dirname, join, resolve as resolvePath } from 'path'; -import resolveModule from 'resolve'; -import { AppMeta, explicitRelative, hbsToJS, Resolution, Resolver, ResolverOptions } from '@embroider/core'; +import { AppMeta, explicitRelative, hbsToJS, Resolver, ResolverOptions } from '@embroider/core'; import { Memoize } from 'typescript-memoize'; import chalk from 'chalk'; import jsdom from 'jsdom'; @@ -18,7 +17,6 @@ import { import { AuditBuildOptions, AuditOptions } from './audit/options'; import { buildApp, BuildError, isBuildError } from './audit/build'; import { AuditMessage } from './resolver'; -import assertNever from 'assert-never'; const { JSDOM } = jsdom; @@ -84,11 +82,6 @@ function isLinked(module: InternalModule | undefined): module is LinkedInternalM return Boolean(module?.parsed && module.resolved && module.linked); } -interface Request { - specifier: string; - fromFile: string; -} - export interface Import { source: string; specifiers: { @@ -255,15 +248,12 @@ export class Audit { return config; } - @Memoize() private get resolverParams(): ResolverOptions { - let config = { - ...readJSONSync(join(this.appDir, '_adjust_imports.json')), - ...readJSONSync(join(this.appDir, '_relocated_files.json')), - }; - return config; + return readJSONSync(join(this.appDir, '.embroider', 'resolver.json')); } + private resolver = new Resolver(this.resolverParams); + private debug(message: string, ...args: any[]) { if (this.options.debug) { console.log(message, ...args); @@ -309,9 +299,8 @@ export class Audit { } else { module.parsed = visitResult; let resolved = new Map() as NonNullable; - let resolver = new Resolver(this.resolverParams); for (let dep of visitResult.dependencies) { - let depFilename = await this.resolve({ specifier: dep, fromFile: filename }, resolver); + let depFilename = await this.resolve(dep, filename); if (depFilename) { resolved.set(dep, depFilename); if (!isResolutionFailure(depFilename)) { @@ -536,63 +525,21 @@ export class Audit { return this.visitJS(filename, js); } - private nextRequest( - prevRequest: { specifier: string; fromFile: string }, - resolution: Resolution - ): { specifier: string; fromFile: string } | undefined { - switch (resolution.result) { - case 'virtual': - // nothing to audit - return undefined; - case 'alias': - // follow the redirect - let specifier = resolution.specifier; - let fromFile = resolution.fromFile ?? prevRequest.fromFile; - return { specifier, fromFile }; - case 'rehome': - return { specifier: prevRequest.specifier, fromFile: resolution.fromFile }; - case 'continue': - return prevRequest; - default: - throw assertNever(resolution); + private async resolve(specifier: string, fromFile: string) { + let resolution = await this.resolver.nodeResolve(specifier, fromFile); + if (resolution.type === 'virtual') { + // nothing to audit + return undefined; } - } - - private async resolve(request: Request, resolver: Resolver): Promise { - let current: Request | undefined = request; - - while (true) { - current = this.nextRequest(current, resolver.beforeResolve(current.specifier, current.fromFile)); - if (!current) { - return; - } - - if (['@embroider/macros', '@ember/template-factory'].includes(current.specifier)) { + if (resolution.type === 'not_found') { + if (['@embroider/macros', '@ember/template-factory'].includes(specifier)) { // the audit process deliberately removes the @embroider/macros babel // plugins, so the imports are still present and should be left alone. return; } - try { - return resolveModule.sync(current.specifier, { - basedir: dirname(current.fromFile), - extensions: this.meta['resolvable-extensions'], - }); - } catch (err) { - if (err.code !== 'MODULE_NOT_FOUND') { - throw err; - } - let retry = this.nextRequest(current, resolver.fallbackResolve(current.specifier, current.fromFile)); - if (!retry) { - // the request got virtualized - return; - } - if (retry === current) { - // the fallback has nothing new to offer, so this is a real resolution failure - return { isResolutionFailure: true }; - } - current = retry; - } + return { isResolutionFailure: true as true }; } + return resolution.filename; } private pushFinding(finding: Finding) { diff --git a/packages/compat/src/compat-app.ts b/packages/compat/src/compat-app.ts index 9813a30dc..b59eedd9c 100644 --- a/packages/compat/src/compat-app.ts +++ b/packages/compat/src/compat-app.ts @@ -12,6 +12,7 @@ import { EmberENV, Package, AddonPackage, + Engine, } from '@embroider/core'; import V1InstanceCache from './v1-instance-cache'; import V1App from './v1-app'; @@ -19,9 +20,9 @@ import walkSync from 'walk-sync'; import { join } from 'path'; import { JSDOM } from 'jsdom'; import { V1Config } from './v1-config'; -import { statSync, readdirSync, writeFileSync } from 'fs'; +import { statSync, readdirSync } from 'fs'; import Options, { optionsWithDefaults } from './options'; -import CompatResolver from './resolver'; +import CompatResolver, { CompatResolverOptions } from './resolver'; import { activePackageRules, PackageRules, expandModuleRules } from './dependency-rules'; import flatMap from 'lodash/flatMap'; import { Memoize } from 'typescript-memoize'; @@ -30,8 +31,8 @@ import { sync as resolveSync } from 'resolve'; import { MacrosConfig } from '@embroider/macros/src/node'; import bind from 'bind-decorator'; import { pathExistsSync } from 'fs-extra'; -import { tmpdir, ResolverOptions } from '@embroider/core'; import type { Transform } from 'babel-plugin-ember-template-compilation'; +import type { Options as ResolverTransformOptions } from './resolver-transform'; interface TreeNames { appJS: BroccoliNode; @@ -88,7 +89,7 @@ function setup(legacyEmberAppInstance: object, options: Required) { return { inTrees, instantiate }; } -class CompatAppAdapter implements AppAdapter { +class CompatAppAdapter implements AppAdapter { constructor( private root: string, private appPackage: Package, @@ -223,7 +224,7 @@ class CompatAppAdapter implements AppAdapter { } @Memoize() - private resolvableExtensions(): string[] { + resolvableExtensions(): string[] { // webpack's default is ['.wasm', '.mjs', '.js', '.json']. Keeping that // subset in that order is sensible, since many third-party libraries will // expect it to work that way. @@ -321,75 +322,63 @@ class CompatAppAdapter implements AppAdapter { } @Memoize() - resolverTransform(): Transform | undefined { - return new CompatResolver({ - emberVersion: this.activeAddonChildren().find(a => a.name === 'ember-source')!.packageJSON.version, - root: this.root, - modulePrefix: this.modulePrefix(), - podModulePrefix: this.podModulePrefix(), - options: this.options, - activePackageRules: this.activeRules(), - adjustImportsOptionsPath: this.adjustImportsOptionsPath(), - }).astTransformer(); - } - - @Memoize() - adjustImportsOptionsPath(): string { - let file = join(this.root, '_adjust_imports.json'); - writeFileSync(file, JSON.stringify(this.resolverConfig())); - return file; - } - - @Memoize() - resolverConfig(): ResolverOptions { - return this.makeAdjustImportOptions(true); + resolverTransform(resolverConfig: CompatResolverOptions): Transform | undefined { + if ( + this.options.staticComponents || + this.options.staticHelpers || + this.options.staticModifiers || + (globalThis as any).embroider_audit + ) { + let opts: ResolverTransformOptions = { + appRoot: resolverConfig.appRoot, + emberVersion: resolverConfig.emberVersion, + }; + return [require.resolve('./resolver-transform'), opts]; + } } - // this gets serialized out by babel plugin and ast plugin - private makeAdjustImportOptions(outer: boolean): ResolverOptions { + resolverConfig(engines: Engine[]): CompatResolverOptions { let renamePackages = Object.assign({}, ...this.allActiveAddons.map(dep => dep.meta['renamed-packages'])); let renameModules = Object.assign({}, ...this.allActiveAddons.map(dep => dep.meta['renamed-modules'])); - let activeAddons: ResolverOptions['activeAddons'] = {}; + let activeAddons: CompatResolverOptions['activeAddons'] = {}; for (let addon of this.allActiveAddons) { activeAddons[addon.name] = addon.root; } - return { + let relocatedFiles: CompatResolverOptions['relocatedFiles'] = {}; + for (let { destPath, appFiles } of engines) { + for (let [relativePath, originalPath] of appFiles.relocatedFiles) { + relocatedFiles[join(destPath, relativePath)] = originalPath; + } + } + + let config: CompatResolverOptions = { + // this part is the base ModuleResolverOptions as required by @embroider/core activeAddons, renameModules, renamePackages, - // "outer" here prevents uncontrolled recursion. We can't know our - // extraImports until after we have the internalTemplateResolver which in - // turn needs some adjustImportsOptions - extraImports: outer ? this.extraImports() : [], - relocatedFiles: {}, // this is the only part we can't completely fill out here. It needs to wait for the AppBuilder to finish smooshing together all appTrees + extraImports: [], // extraImports gets filled in below + relocatedFiles, resolvableExtensions: this.resolvableExtensions(), - - // it's important that this is a persistent location, because we fill it - // up as a side-effect of babel transpilation, and babel is subject to - // persistent caching. - externalsDir: join(tmpdir, 'embroider', 'externals'), appRoot: this.root, - }; - } - // unlike `templateResolver`, this one brings its own simple TemplateCompiler - // along so it's capable of parsing component snippets in people's module - // rules. - @Memoize() - private internalTemplateResolver(): CompatResolver { - return new CompatResolver({ + // this is the additional stufff that @embroider/compat adds on top to do + // global template resolving emberVersion: this.activeAddonChildren().find(a => a.name === 'ember-source')!.packageJSON.version, - root: this.root, modulePrefix: this.modulePrefix(), + podModulePrefix: this.podModulePrefix(), options: this.options, activePackageRules: this.activeRules(), - adjustImportsOptions: this.makeAdjustImportOptions(false), - }); + }; + + this.addExtraImports(config); + return config; } - private extraImports() { + private addExtraImports(config: CompatResolverOptions) { + let internalResolver = new CompatResolver(config); + let output: { absPath: string; target: string; runtimeName?: string }[][] = []; for (let rule of this.activeRules()) { @@ -397,18 +386,18 @@ class CompatAppAdapter implements AppAdapter { for (let [filename, moduleRules] of Object.entries(rule.addonModules)) { for (let root of rule.roots) { let absPath = join(root, filename); - output.push(expandModuleRules(absPath, moduleRules, this.internalTemplateResolver())); + output.push(expandModuleRules(absPath, moduleRules, internalResolver)); } } } if (rule.appModules) { for (let [filename, moduleRules] of Object.entries(rule.appModules)) { let absPath = join(this.root, filename); - output.push(expandModuleRules(absPath, moduleRules, this.internalTemplateResolver())); + output.push(expandModuleRules(absPath, moduleRules, internalResolver)); } } } - return flatten(output); + config.extraImports = flatten(output); } htmlbarsPlugins(): Transform[] { diff --git a/packages/compat/src/resolver-transform.ts b/packages/compat/src/resolver-transform.ts index e4110be80..219269dea 100644 --- a/packages/compat/src/resolver-transform.ts +++ b/packages/compat/src/resolver-transform.ts @@ -1,4 +1,4 @@ -import { +import CompatResolver, { default as Resolver, ComponentResolution, ComponentLocator, @@ -11,6 +11,10 @@ import { import type { ASTv1, ASTPluginBuilder, ASTPluginEnvironment, WalkerPath } from '@glimmer/syntax'; import type { WithJSUtils } from 'babel-plugin-ember-template-compilation'; import assertNever from 'assert-never'; +import { explicitRelative } from '@embroider/core'; +import { dirname, join } from 'path'; +import { readJSONSync } from 'fs-extra'; +import semver from 'semver'; type Env = WithJSUtils & { filename: string; @@ -20,12 +24,18 @@ type Env = WithJSUtils & { }; export interface Options { - resolver: Resolver; - patchHelpersBug: boolean; + appRoot: string; + emberVersion: string; } // This is the AST transform that resolves components, helpers and modifiers at build time -export default function makeResolverTransform({ resolver, patchHelpersBug }: Options) { +export default function makeResolverTransform({ appRoot, emberVersion }: Options) { + // lexical invocation of helpers was not reliable before Ember 4.2 due to https://github.com/emberjs/ember.js/pull/19878 + let patchHelpersBug = semver.satisfies(emberVersion, '<4.2.0-beta.0', { + includePrerelease: true, + }); + + let resolver = new CompatResolver(readJSONSync(join(appRoot, '.embroider', 'resolver.json'))); const resolverTransform: ASTPluginBuilder = env => { let { filename, @@ -39,6 +49,10 @@ export default function makeResolverTransform({ resolver, patchHelpersBug }: Opt let scopeStack = new ScopeStack(); let emittedAMDDeps: Set = new Set(); + function relativeToFile(absPath: string): string { + return explicitRelative(dirname(filename), absPath); + } + const invokeDependencies = resolver.enter(filename); for (let packageRuleInvokeDependency of invokeDependencies) { emitAMD(packageRuleInvokeDependency.hbsModule); @@ -60,9 +74,9 @@ export default function makeResolverTransform({ resolver, patchHelpersBug }: Opt function emitAMD(dep: ResolvedDep | null) { if (dep && !emittedAMDDeps.has(dep.runtimeName)) { let parts = dep.runtimeName.split('/'); - let { path, runtimeName } = dep; + let { absPath, runtimeName } = dep; jsutils.emitExpression(context => { - let identifier = context.import(path, 'default', parts[parts.length - 1]); + let identifier = context.import(relativeToFile(absPath), 'default', parts[parts.length - 1]); return `window.define("${runtimeName}", () => ${identifier})`; }); emittedAMDDeps.add(dep.runtimeName); @@ -83,7 +97,7 @@ export default function makeResolverTransform({ resolver, patchHelpersBug }: Opt // lexical invocation of helpers was not reliable before Ember 4.2 due to https://github.com/emberjs/ember.js/pull/19878 emitAMD(resolution.module); } else { - let name = jsutils.bindImport(resolution.module.path, 'default', parentPath, { + let name = jsutils.bindImport(relativeToFile(resolution.module.absPath), 'default', parentPath, { nameHint: resolution.nameHint, }); emittedLexicalBindings.set(name, resolution); @@ -91,7 +105,7 @@ export default function makeResolverTransform({ resolver, patchHelpersBug }: Opt } return; case 'modifier': - let name = jsutils.bindImport(resolution.module.path, 'default', parentPath, { + let name = jsutils.bindImport(relativeToFile(resolution.module.absPath), 'default', parentPath, { nameHint: resolution.nameHint, }); emittedLexicalBindings.set(name, resolution); @@ -109,7 +123,7 @@ export default function makeResolverTransform({ resolver, patchHelpersBug }: Opt // component and the template in the AMD loader to associate them. In // that case, we emit just-in-time AMD definitions for them. if (resolution.jsModule && !resolution.hbsModule) { - let name = jsutils.bindImport(resolution.jsModule.path, 'default', parentPath, { + let name = jsutils.bindImport(relativeToFile(resolution.jsModule.absPath), 'default', parentPath, { nameHint: resolution.nameHint, }); emittedLexicalBindings.set(name, resolution); diff --git a/packages/compat/src/resolver.ts b/packages/compat/src/resolver.ts index 170505423..1883212e9 100644 --- a/packages/compat/src/resolver.ts +++ b/packages/compat/src/resolver.ts @@ -9,23 +9,18 @@ import { import { Package, PackageCache, - explicitRelative, extensionsPattern, ResolverOptions as CoreResolverOptions, + Resolver, } from '@embroider/core'; -import { dirname, join, relative, sep } from 'path'; +import { join, relative, sep, resolve as pathResolve } from 'path'; import { Memoize } from 'typescript-memoize'; import Options from './options'; import { dasherize, snippetToDasherizedName } from './dasherize-component-name'; -import { pathExistsSync } from 'fs-extra'; -import resolve from 'resolve'; -import semver from 'semver'; -import { Options as ResolverTransformOptions } from './resolver-transform'; export interface ResolvedDep { runtimeName: string; - path: string; absPath: string; } @@ -47,7 +42,7 @@ export interface HelperResolution { export interface ModifierResolution { type: 'modifier'; - module: ResolvedDep; + module: { absPath: string }; nameHint: string; } @@ -123,12 +118,12 @@ const builtInModifiers = ['action', 'on']; // this is a subset of the full Options. We care about serializability, and we // only needs parts that are easily serializable, which is why we don't keep the // whole thing. -type ResolverOptions = Pick< +type UserConfig = Pick< Required, 'staticHelpers' | 'staticModifiers' | 'staticComponents' | 'allowUnsafeDynamicComponents' >; -function extractOptions(options: Required | ResolverOptions): ResolverOptions { +function extractOptions(options: Required | UserConfig): UserConfig { return { staticHelpers: options.staticHelpers, staticModifiers: options.staticModifiers, @@ -137,27 +132,12 @@ function extractOptions(options: Required | ResolverOptions): ResolverO }; } -interface RehydrationParamsBase { - root: string; +export interface CompatResolverOptions extends CoreResolverOptions { modulePrefix: string; podModulePrefix?: string; - options: ResolverOptions; emberVersion: string; activePackageRules: ActivePackageRules[]; -} - -interface RehydrationParamsWithFile extends RehydrationParamsBase { - adjustImportsOptionsPath: string; -} - -interface RehydrationParamsWithOptions extends RehydrationParamsBase { - adjustImportsOptions: CoreResolverOptions; -} - -type RehydrationParams = RehydrationParamsWithFile | RehydrationParamsWithOptions; - -export function rehydrate(params: RehydrationParams) { - return new CompatResolver(params); + options: UserConfig; } export interface AuditMessage { @@ -171,19 +151,11 @@ export interface AuditMessage { export default class CompatResolver { private auditHandler: undefined | ((msg: AuditMessage) => void); - _parallelBabel: { - requireFile: string; - buildUsing: string; - params: RehydrationParams; - }; + private resolver: Resolver; - constructor(private params: RehydrationParams) { + constructor(private params: CompatResolverOptions) { this.params.options = extractOptions(this.params.options); - this._parallelBabel = { - requireFile: __filename, - buildUsing: 'rehydrate', - params, - }; + this.resolver = new Resolver(this.params); if ((globalThis as any).embroider_audit) { this.auditHandler = (globalThis as any).embroider_audit; } @@ -211,7 +183,7 @@ export default class CompatResolver { // "inherit" the rules that are attached to their corresonding JS module. if (absPath.endsWith('.hbs')) { let stem = absPath.slice(0, -4); - for (let ext of this.adjustImportsOptions.resolvableExtensions) { + for (let ext of this.params.resolvableExtensions) { if (ext !== '.hbs') { let rules = this.rules.components.get(stem + ext); if (rules) { @@ -227,18 +199,9 @@ export default class CompatResolver { return this.rules.ignoredComponents.includes(dasherizedName); } - @Memoize() - get adjustImportsOptions(): CoreResolverOptions { - const { params } = this; - return 'adjustImportsOptionsPath' in params - ? // eslint-disable-next-line @typescript-eslint/no-require-imports - require(params.adjustImportsOptionsPath) - : params.adjustImportsOptions; - } - @Memoize() private get rules() { - // keyed by their first resolved dependency's runtimeName. + // keyed by their first resolved dependency's absPath. let components: Map = new Map(); // keyed by our own dasherized interpretation of the component's name. @@ -269,7 +232,7 @@ export default class CompatResolver { // those templates. if (componentRules.layout) { if (componentRules.layout.appPath) { - components.set(join(this.params.root, componentRules.layout.appPath), processedRules); + components.set(join(this.params.appRoot, componentRules.layout.appPath), processedRules); } else if (componentRules.layout.addonPath) { for (let root of rule.roots) { components.set(join(root, componentRules.layout.addonPath), processedRules); @@ -289,7 +252,7 @@ export default class CompatResolver { if (rule.appTemplates) { for (let [path, templateRules] of Object.entries(rule.appTemplates)) { let processedRules = preprocessComponentRule(templateRules); - components.set(join(this.params.root, path), processedRules); + components.set(join(this.params.appRoot, path), processedRules); } } if (rule.addonTemplates) { @@ -325,25 +288,12 @@ export default class CompatResolver { return name; } - astTransformer(): undefined | string | [string, unknown] { - if (this.staticComponentsEnabled || this.staticHelpersEnabled || this.staticModifiersEnabled) { - let opts: ResolverTransformOptions = { - resolver: this, - // lexical invocation of helpers was not reliable before Ember 4.2 due to https://github.com/emberjs/ember.js/pull/19878 - patchHelpersBug: semver.satisfies(this.params.emberVersion, '<4.2.0-beta.0', { - includePrerelease: true, - }), - }; - return [require.resolve('./resolver-transform'), opts]; - } - } - private humanReadableFile(file: string) { - if (!this.params.root.endsWith('/')) { - this.params.root += '/'; + if (!this.params.appRoot.endsWith('/')) { + this.params.appRoot += '/'; } - if (file.startsWith(this.params.root)) { - return file.slice(this.params.root.length); + if (file.startsWith(this.params.appRoot)) { + return file.slice(this.params.appRoot.length); } return file; } @@ -370,47 +320,39 @@ export default class CompatResolver { } resolveImport(path: string, from: string): { runtimeName: string; absPath: string } | undefined { - let absPath; - try { - absPath = resolve.sync(path, { - basedir: dirname(from), - extensions: this.adjustImportsOptions.resolvableExtensions, - }); - } catch (err) { - return; - } - if (absPath) { - let runtimeName = this.absPathToRuntimeName(absPath); + let resolution = this.resolver.nodeResolve(path, from); + if (resolution.type === 'real') { + let runtimeName = this.absPathToRuntimeName(resolution.filename); if (runtimeName) { - return { runtimeName, absPath }; + return { runtimeName, absPath: resolution.filename }; } } } @Memoize() private get resolvableExtensionsPattern() { - return extensionsPattern(this.adjustImportsOptions.resolvableExtensions); + return extensionsPattern(this.params.resolvableExtensions); } private absPathToRuntimePath(absPath: string, owningPackage?: { root: string; name: string }) { - let pkg = owningPackage || PackageCache.shared('embroider-stage3', this.params.root).ownerOfFile(absPath); + let pkg = owningPackage || PackageCache.shared('embroider-stage3', this.params.appRoot).ownerOfFile(absPath); if (pkg) { let packageRuntimeName = pkg.name; - for (let [runtimeName, realName] of Object.entries(this.adjustImportsOptions.renamePackages)) { + for (let [runtimeName, realName] of Object.entries(this.params.renamePackages)) { if (realName === packageRuntimeName) { packageRuntimeName = runtimeName; break; } } return join(packageRuntimeName, relative(pkg.root, absPath)).split(sep).join('/'); - } else if (absPath.startsWith(this.params.root)) { - return join(this.params.modulePrefix, relative(this.params.root, absPath)).split(sep).join('/'); + } else if (absPath.startsWith(this.params.appRoot)) { + return join(this.params.modulePrefix, relative(this.params.appRoot, absPath)).split(sep).join('/'); } else { throw new Error(`bug: can't figure out the runtime name for ${absPath}`); } } - absPathToRuntimeName(absPath: string, owningPackage?: { root: string; name: string }) { + private absPathToRuntimeName(absPath: string, owningPackage?: { root: string; name: string }) { return this.absPathToRuntimePath(absPath, owningPackage) .replace(this.resolvableExtensionsPattern, '') .replace(/\/index$/, ''); @@ -428,184 +370,112 @@ export default class CompatResolver { return this.params.options.staticModifiers || Boolean(this.auditHandler); } - private tryHelper(path: string, from: string): HelperResolution | null { + private containingEngine(_filename: string): Package | AppPackagePlaceholder { + // FIXME: when using engines, template global resolution is scoped to the + // engine not always the app. We already have code in the app-tree-merging + // to deal with that, so as we unify that with the module-resolving system + // we should be able to generate a better answer here. + return this.appPackage; + } + + private parsePath(path: string, fromFile: string) { + let engine = this.containingEngine(fromFile); let parts = path.split('@'); if (parts.length > 1 && parts[0].length > 0) { - let cache = PackageCache.shared('embroider-stage3', this.params.root); - let packageName = parts[0]; - let renamed = this.adjustImportsOptions.renamePackages[packageName]; - if (renamed) { - packageName = renamed; - } - let owner = cache.ownerOfFile(from)!; - let targetPackage = owner.name === packageName ? owner : cache.resolve(packageName, owner); - return this._tryHelper(parts[1], from, targetPackage); + return { packageName: parts[0], memberName: parts[1], from: pathResolve(engine.root, './package.json') }; } else { - return this._tryHelper(path, from, this.appPackage); + return { packageName: engine.name, memberName: path, from: pathResolve(engine.root, './package.json') }; } } - private _tryHelper( - path: string, - from: string, - targetPackage: Package | AppPackagePlaceholder - ): HelperResolution | null { - for (let extension of this.adjustImportsOptions.resolvableExtensions) { - let absPath = join(targetPackage.root, 'helpers', path) + extension; - if (pathExistsSync(absPath)) { - return { - type: 'helper', - module: { - runtimeName: this.absPathToRuntimeName(absPath, targetPackage), - path: explicitRelative(dirname(from), absPath), - absPath, - }, - nameHint: path, - }; - } + private tryHelper(path: string, from: string): HelperResolution | null { + let target = this.parsePath(path, from); + let runtimeName = `${target.packageName}/helpers/${target.memberName}`; + let resolution = this.resolver.nodeResolve(runtimeName, target.from); + if (resolution.type === 'real') { + return { + type: 'helper', + module: { + absPath: resolution.filename, + runtimeName, + }, + nameHint: target.memberName, + }; } return null; } private tryModifier(path: string, from: string): ModifierResolution | null { - let parts = path.split('@'); - if (parts.length > 1 && parts[0].length > 0) { - let cache = PackageCache.shared('embroider-stage3', this.params.root); - let packageName = parts[0]; - let renamed = this.adjustImportsOptions.renamePackages[packageName]; - if (renamed) { - packageName = renamed; - } - let owner = cache.ownerOfFile(from)!; - let targetPackage = owner.name === packageName ? owner : cache.resolve(packageName, owner); - return this._tryModifier(parts[1], from, targetPackage); - } else { - return this._tryModifier(path, from, this.appPackage); - } - } - - private _tryModifier( - path: string, - from: string, - targetPackage: Package | AppPackagePlaceholder - ): ModifierResolution | null { - for (let extension of this.adjustImportsOptions.resolvableExtensions) { - let absPath = join(targetPackage.root, 'modifiers', path) + extension; - if (pathExistsSync(absPath)) { - return { - type: 'modifier', - module: { - runtimeName: this.absPathToRuntimeName(absPath, targetPackage), - path: explicitRelative(dirname(from), absPath), - absPath, - }, - nameHint: path, - }; - } + let target = this.parsePath(path, from); + let resolution = this.resolver.nodeResolve(`${target.packageName}/modifiers/${target.memberName}`, target.from); + if (resolution.type === 'real') { + return { + type: 'modifier', + module: { + absPath: resolution.filename, + }, + nameHint: path, + }; } return null; } @Memoize() private get appPackage(): AppPackagePlaceholder { - return { root: this.params.root, name: this.params.modulePrefix }; + return { root: this.params.appRoot, name: this.params.modulePrefix }; } - private tryComponent(path: string, from: string, withRuleLookup = true): ComponentResolution | null { - let parts = path.split('@'); - if (parts.length > 1 && parts[0].length > 0) { - let cache = PackageCache.shared('embroider-stage3', this.params.root); - let packageName = parts[0]; - let renamed = this.adjustImportsOptions.renamePackages[packageName]; - if (renamed) { - packageName = renamed; - } - let owner = cache.ownerOfFile(from)!; - let targetPackage = owner.name === packageName ? owner : cache.resolve(packageName, owner); + private *componentTemplateCandidates(target: { packageName: string; memberName: string }) { + yield `${target.packageName}/templates/components/${target.memberName}`; + yield `${target.packageName}/components/${target.memberName}/template`; - return this._tryComponent(parts[1], from, withRuleLookup, targetPackage); - } else { - return this._tryComponent(path, from, withRuleLookup, this.appPackage); + if ( + typeof this.params.podModulePrefix !== 'undefined' && + this.params.podModulePrefix !== '' && + target.packageName === this.appPackage.name + ) { + yield `${this.params.podModulePrefix}/components/${target.memberName}/template`; } } - private _tryComponent( - path: string, - from: string, - withRuleLookup: boolean, - targetPackage: Package | AppPackagePlaceholder - ): ComponentResolution | null { - let extensions = ['.hbs', ...this.adjustImportsOptions.resolvableExtensions.filter((e: string) => e !== '.hbs')]; - - let hbsModule: string | undefined; - let jsModule: string | undefined; - - // first, the various places our template might be - for (let extension of extensions) { - let absPath = join(targetPackage.root, 'templates', 'components', path) + extension; - if (pathExistsSync(absPath)) { - hbsModule = absPath; - break; - } - - absPath = join(targetPackage.root, 'components', path, 'template') + extension; - if (pathExistsSync(absPath)) { - hbsModule = absPath; - break; - } - - if ( - typeof this.params.podModulePrefix !== 'undefined' && - this.params.podModulePrefix !== '' && - targetPackage === this.appPackage - ) { - let podPrefix = this.params.podModulePrefix.replace(this.params.modulePrefix, ''); + private *componentJSCandidates(target: { packageName: string; memberName: string }) { + yield `${target.packageName}/components/${target.memberName}`; + yield `${target.packageName}/components/${target.memberName}/component`; - absPath = join(targetPackage.root, podPrefix, 'components', path, 'template') + extension; - if (pathExistsSync(absPath)) { - hbsModule = absPath; - break; - } - } + if ( + typeof this.params.podModulePrefix !== 'undefined' && + this.params.podModulePrefix !== '' && + target.packageName === this.appPackage.name + ) { + yield `${this.params.podModulePrefix}/components/${target.memberName}/component`; } + } - // then the various places our javascript might be - for (let extension of extensions) { - if (extension === '.hbs') { - continue; - } + private tryComponent(path: string, from: string, withRuleLookup = true): ComponentResolution | null { + const target = this.parsePath(path, from); - let absPath = join(targetPackage.root, 'components', path, 'index') + extension; - if (pathExistsSync(absPath)) { - jsModule = absPath; - break; - } + let hbsModule: ResolvedDep | null = null; + let jsModule: ResolvedDep | null = null; - absPath = join(targetPackage.root, 'components', path) + extension; - if (pathExistsSync(absPath)) { - jsModule = absPath; + // first, the various places our template might be. + for (let candidate of this.componentTemplateCandidates(target)) { + let resolution = this.resolver.nodeResolve(candidate, target.from); + if (resolution.type === 'real') { + hbsModule = { absPath: resolution.filename, runtimeName: candidate }; break; } + } - absPath = join(targetPackage.root, 'components', path, 'component') + extension; - if (pathExistsSync(absPath)) { - jsModule = absPath; + // then the various places our javascript might be. + for (let candidate of this.componentJSCandidates(target)) { + let resolution = this.resolver.nodeResolve(candidate, target.from); + // .hbs is a resolvable extension for us, so we need to exclude it here. + // It matches as a priority lower than .js, so finding an .hbs means + // there's definitely not a .js. + if (resolution.type === 'real' && !resolution.filename.endsWith('.hbs')) { + jsModule = { absPath: resolution.filename, runtimeName: candidate }; break; } - - if ( - typeof this.params.podModulePrefix !== 'undefined' && - this.params.podModulePrefix !== '' && - targetPackage === this.appPackage - ) { - let podPrefix = this.params.podModulePrefix.replace(this.params.modulePrefix, ''); - - absPath = join(targetPackage.root, podPrefix, 'components', path, 'component') + extension; - if (pathExistsSync(absPath)) { - jsModule = absPath; - break; - } - } } if (jsModule == null && hbsModule == null) { @@ -617,28 +487,16 @@ export default class CompatResolver { // the order here is important. We follow the convention that any rules // get attached to the hbsModule if it exists, and only get attached to // the jsModule otherwise - componentRules = this.findComponentRules((hbsModule ?? jsModule)!); + componentRules = this.findComponentRules((hbsModule ?? jsModule)!.absPath); } return { type: 'component', - jsModule: jsModule - ? { - path: explicitRelative(dirname(from), jsModule), - absPath: jsModule, - runtimeName: this.absPathToRuntimeName(jsModule, targetPackage), - } - : null, - hbsModule: hbsModule - ? { - path: explicitRelative(dirname(from), hbsModule), - absPath: hbsModule, - runtimeName: this.absPathToRuntimeName(hbsModule, targetPackage), - } - : null, + jsModule: jsModule, + hbsModule: hbsModule, yieldsComponents: componentRules ? componentRules.yieldsSafeComponents : [], yieldsArguments: componentRules ? componentRules.yieldsArguments : [], argumentsAreComponents: componentRules ? componentRules.argumentsAreComponents : [], - nameHint: path, + nameHint: target.memberName, }; } diff --git a/packages/compat/tests/audit.test.ts b/packages/compat/tests/audit.test.ts index 9d6c7717c..69bff03b8 100644 --- a/packages/compat/tests/audit.test.ts +++ b/packages/compat/tests/audit.test.ts @@ -4,10 +4,12 @@ import { AppMeta, throwOnWarnings } from '@embroider/core'; import merge from 'lodash/merge'; import fromPairs from 'lodash/fromPairs'; import { Audit, Finding } from '../src/audit'; -import CompatResolver from '../src/resolver'; +import { CompatResolverOptions } from '../src/resolver'; import type { TransformOptions } from '@babel/core'; import type { Options as InlinePrecompileOptions } from 'babel-plugin-ember-template-compilation'; import { makePortable } from '@embroider/core/src/portable-babel-config'; +import type { Transform } from 'babel-plugin-ember-template-compilation'; +import type { Options as ResolverTransformOptions } from '../src/resolver-transform'; describe('audit', function () { throwOnWarnings(); @@ -25,9 +27,9 @@ describe('audit', function () { const resolvableExtensions = ['.js', '.hbs']; - let resolver = new CompatResolver({ + let resolverConfig: CompatResolverOptions = { emberVersion: emberTemplateCompiler().version, - root: app.baseDir, + appRoot: app.baseDir, modulePrefix: 'audit-this-app', options: { staticComponents: true, @@ -36,27 +38,24 @@ describe('audit', function () { allowUnsafeDynamicComponents: false, }, activePackageRules: [], - adjustImportsOptions: { - renamePackages: {}, - renameModules: {}, - extraImports: [], - externalsDir: '/tmp/embroider-externals', - activeAddons: {}, - relocatedFiles: {}, - resolvableExtensions, - appRoot: '.', - }, - }); + renamePackages: {}, + renameModules: {}, + extraImports: [], + activeAddons: {}, + relocatedFiles: {}, + resolvableExtensions, + }; let babel: TransformOptions = { babelrc: false, plugins: [], }; - let transform = resolver.astTransformer(); - if (!transform) { - throw new Error('bug: expected astTransformer'); - } + let transformOpts: ResolverTransformOptions = { + appRoot: resolverConfig.appRoot, + emberVersion: resolverConfig.emberVersion, + }; + let transform: Transform = [require.resolve('../src/resolver-transform'), transformOpts]; let etcOptions: InlinePrecompileOptions = { compilerPath: emberTemplateCompiler().path, @@ -74,8 +73,9 @@ describe('audit', function () { null, 2 )}`, - '_adjust_imports.json': JSON.stringify(resolver.adjustImportsOptions), - '_relocated_files.json': JSON.stringify({}), + '.embroider': { + 'resolver.json': JSON.stringify(resolverConfig), + }, }); let appMeta: AppMeta = { type: 'app', @@ -87,11 +87,12 @@ describe('audit', function () { majorVersion: 7, fileFilter: 'babel_filter.js', }, - 'resolvable-extensions': resolvableExtensions, 'root-url': '/', + 'auto-upgraded': true, }; merge(app.pkg, { 'ember-addon': appMeta, + keywords: ['ember-addon'], }); }); diff --git a/packages/compat/tests/resolver.test.ts b/packages/compat/tests/resolver.test.ts index 7c1c94b54..55105b9f7 100644 --- a/packages/compat/tests/resolver.test.ts +++ b/packages/compat/tests/resolver.test.ts @@ -1,18 +1,36 @@ -import { removeSync, mkdtempSync, writeFileSync, ensureDirSync, writeJSONSync, realpathSync } from 'fs-extra'; +import { + removeSync, + mkdtempSync, + writeFileSync, + ensureDirSync, + writeJSONSync, + realpathSync, + outputJSONSync, +} from 'fs-extra'; import { join, dirname } from 'path'; import Options, { optionsWithDefaults } from '../src/options'; -import { hbsToJS, tmpdir, throwOnWarnings, ResolverOptions } from '@embroider/core'; +import { hbsToJS, tmpdir, throwOnWarnings, ResolverOptions, AddonMeta } from '@embroider/core'; import { emberTemplateCompiler } from '@embroider/test-support'; -import Resolver from '../src/resolver'; +import { CompatResolverOptions } from '../src/resolver'; import { PackageRules } from '../src'; import type { AST, ASTPluginEnvironment } from '@glimmer/syntax'; import 'code-equality-assertions/jest'; import type { Transform, Options as EtcOptions } from 'babel-plugin-ember-template-compilation'; import { TransformOptions, transformSync } from '@babel/core'; +import type { Options as ResolverTransformOptions } from '../src/resolver-transform'; describe('compat-resolver', function () { let appDir: string; + function addonPackageJSON(name: string) { + let meta: AddonMeta = { type: 'addon', version: 2, 'auto-upgraded': true }; + return JSON.stringify({ + name, + keywords: ['ember-addon'], + 'ember-addon': meta, + }); + } + function configure( compatOptions: Options, otherOptions: { @@ -23,10 +41,14 @@ describe('compat-resolver', function () { } = {} ) { appDir = realpathSync(mkdtempSync(join(tmpdir, 'embroider-compat-tests-'))); - writeJSONSync(join(appDir, 'package.json'), { name: 'the-app' }); - let resolver = new Resolver({ + writeJSONSync(join(appDir, 'package.json'), { + name: 'the-app', + keywords: ['ember-addon'], + 'ember-addon': { type: 'app', version: 2, 'auto-upgraded': true }, + }); + let resolverConfig: CompatResolverOptions = { emberVersion: emberTemplateCompiler().version, - root: appDir, + appRoot: appDir, modulePrefix: 'the-app', podModulePrefix: otherOptions.podModulePrefix, options: optionsWithDefaults(compatOptions), @@ -34,23 +56,22 @@ describe('compat-resolver', function () { let root = rule.package === 'the-test-package' ? appDir : `${appDir}/node_modules/${rule.package}`; return Object.assign({ roots: [root] }, rule); }), - adjustImportsOptions: Object.assign( - { - renamePackages: {}, - renameModules: {}, - extraImports: [], - externalsDir: '/tmp/embroider-externals', - activeAddons: {}, - relocatedFiles: {}, - resolvableExtensions: ['.js', '.hbs'], - appRoot: appDir, - }, - otherOptions.adjustImportsImports - ), - }); + renamePackages: {}, + renameModules: {}, + extraImports: [], + activeAddons: {}, + relocatedFiles: {}, + resolvableExtensions: ['.js', '.hbs'], + ...otherOptions.adjustImportsImports, + }; let transforms: Transform[] = []; - let resolverTransform = resolver.astTransformer(); + + let transformOpts: ResolverTransformOptions = { + appRoot: resolverConfig.appRoot, + emberVersion: resolverConfig.emberVersion, + }; + let resolverTransform: Transform = [require.resolve('../src/resolver-transform'), transformOpts]; if (otherOptions.plugins) { transforms.push.apply(transforms, otherOptions.plugins); @@ -67,6 +88,8 @@ describe('compat-resolver', function () { plugins: [[require.resolve('babel-plugin-ember-template-compilation'), etcOptions]], }; + outputJSONSync(join(appDir, '.embroider', 'resolver.json'), resolverConfig); + return function (relativePath: string, contents: string) { let jsInput = otherOptions?.startingFrom === 'js' ? contents : hbsToJS(contents, { filename: `my-app/${relativePath}` }); @@ -875,7 +898,7 @@ describe('compat-resolver', function () { let transform = configure({ staticComponents: true, }); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon"}`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/components/thing.js'); expect(transform('templates/application.hbs', `{{component "my-addon@thing"}}`)).toEqualCode(` import thing from "../node_modules/my-addon/components/thing.js"; @@ -901,7 +924,7 @@ describe('compat-resolver', function () { }, } ); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon"}`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/components/thing.js'); expect(transform('templates/application.hbs', `{{component "has-been-renamed@thing"}}`)).toEqualCode(` import thing from "../node_modules/my-addon/components/thing.js"; @@ -921,7 +944,7 @@ describe('compat-resolver', function () { }, { plugins: [emberHolyFuturisticNamespacingBatmanTransform] } ); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon"}`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/components/thing.js'); expect(transform('templates/application.hbs', ``)).toEqualCode(` import MyAddonThing from "../node_modules/my-addon/components/thing.js"; @@ -941,7 +964,7 @@ describe('compat-resolver', function () { }, { plugins: [emberHolyFuturisticNamespacingBatmanTransform] } ); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon"}`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/components/thing.js'); expect(transform('node_modules/my-addon/components/foo.hbs', ``)).toEqualCode(` import MyAddonThing from "./thing.js"; @@ -961,7 +984,7 @@ describe('compat-resolver', function () { }, { plugins: [emberHolyFuturisticNamespacingBatmanTransform] } ); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon" }`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/helpers/thing.js'); expect(transform('templates/application.hbs', `{{my-addon$thing}}`)).toEqualCode(` import thing from "../node_modules/my-addon/helpers/thing.js"; @@ -988,7 +1011,7 @@ describe('compat-resolver', function () { plugins: [emberHolyFuturisticNamespacingBatmanTransform], } ); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon"}`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/helpers/thing.js'); expect(transform('templates/application.hbs', `{{has-been-renamed$thing}}`)).toEqualCode(` import thing from "../node_modules/my-addon/helpers/thing.js"; @@ -2386,7 +2409,7 @@ describe('compat-resolver', function () { }, ]; let transform = configure({ staticComponents: true, packageRules }); - givenFile('node_modules/my-addon/package.json', `{ "name": "my-addon"}`); + givenFile('node_modules/my-addon/package.json', addonPackageJSON('my-addon')); givenFile('node_modules/my-addon/templates/index.hbs'); givenFile('templates/components/alpha.hbs'); givenFile('components/alpha.js'); diff --git a/packages/core/src/app-files.ts b/packages/core/src/app-files.ts index 82a766fba..0dcd8175b 100644 --- a/packages/core/src/app-files.ts +++ b/packages/core/src/app-files.ts @@ -1,6 +1,6 @@ import { sep } from 'path'; import { Package, AddonPackage } from '@embroider/shared-internals'; -import AppDiffer from './app-differ'; +import type AppDiffer from './app-differ'; export interface RouteFiles { route?: string; diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 4ed8a996d..8a4c8d6a4 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -11,7 +11,7 @@ import { OutputPaths } from './wait-for-trees'; import { compile } from './js-handlebars'; import resolve from 'resolve'; import { Memoize } from 'typescript-memoize'; -import { copySync, ensureDirSync, readJSONSync, statSync, unlinkSync, writeFileSync } from 'fs-extra'; +import { copySync, ensureDirSync, outputJSONSync, readJSONSync, statSync, unlinkSync, writeFileSync } from 'fs-extra'; import { dirname, join, resolve as resolvePath, sep } from 'path'; import { debug, warn } from './messages'; import sortBy from 'lodash/sortBy'; @@ -35,6 +35,7 @@ import { PortableHint, maybeNodeModuleVersion } from './portable'; import escapeRegExp from 'escape-string-regexp'; import type { Options as EtcOptions, Transform } from 'babel-plugin-ember-template-compilation'; import type { Options as ColocationOptions } from '@embroider/shared-internals/src/template-colocation-plugin'; +import type { Options as AdjustImportsOptions } from './babel-plugin-adjust-imports'; export type EmberENV = unknown; @@ -48,7 +49,7 @@ export type EmberENV = unknown; building apps that don't need an EmberApp instance at all (presumably because they opt into new authoring standards. */ -export interface AppAdapter { +export interface AppAdapter { // the set of all addon packages that are active (recursive) readonly allActiveAddons: AddonPackage[]; @@ -102,13 +103,13 @@ export interface AppAdapter { // Path to a build-time Resolver module to be used during template // compilation. - resolverTransform(): Transform | undefined; + resolverTransform(resolverConfig: SpecificResolverConfig): Transform | undefined; // describes the special module naming rules that we need to achieve // compatibility - resolverConfig(): ResolverConfig; + resolverConfig(engines: Engine[]): SpecificResolverConfig; - adjustImportsOptionsPath(): string; + resolvableExtensions(): string[]; // The template preprocessor plugins that are configured in the app. htmlbarsPlugins(): Transform[]; @@ -254,7 +255,7 @@ export class AppBuilder { @Memoize() private get resolvableExtensionsPattern(): RegExp { - return extensionsPattern(this.adapter.resolverConfig().resolvableExtensions); + return extensionsPattern(this.adapter.resolvableExtensions()); } private impliedAssets( @@ -366,7 +367,7 @@ export class AppBuilder { } @Memoize() - private babelConfig(appFiles: Engine[]) { + private babelConfig(resolverConfig: ResolverConfig) { let babel = cloneDeep(this.adapter.babelConfig()); if (!babel.plugins) { @@ -380,7 +381,7 @@ export class AppBuilder { // https://github.com/webpack/webpack/issues/12154 babel.plugins.push(require.resolve('./rename-require-plugin')); - babel.plugins.push([require.resolve('babel-plugin-ember-template-compilation'), this.etcOptions()]); + babel.plugins.push([require.resolve('babel-plugin-ember-template-compilation'), this.etcOptions(resolverConfig)]); // this is @embroider/macros configured for full stage3 resolution babel.plugins.push(...this.macrosConfig.babelPluginConfig()); @@ -422,7 +423,7 @@ export class AppBuilder { colocationOptions, ]); - babel.plugins.push(this.adjustImportsPlugin(appFiles)); + babel.plugins.push(this.adjustImportsPlugin(resolverConfig)); // we can use globally shared babel runtime by default babel.plugins.push([ @@ -435,22 +436,11 @@ export class AppBuilder { return portable; } - private adjustImportsPlugin(engines: Engine[]): PluginItem { - let relocatedFiles: ResolverConfig['relocatedFiles'] = {}; - for (let { destPath, appFiles } of engines) { - for (let [relativePath, originalPath] of appFiles.relocatedFiles) { - relocatedFiles[join(destPath, relativePath)] = originalPath; - } - } - let relocatedFilesPath = join(this.root, '_relocated_files.json'); - writeFileSync(relocatedFilesPath, JSON.stringify({ relocatedFiles })); - return [ - require.resolve('./babel-plugin-adjust-imports'), - { - adjustImportsOptionsPath: this.adapter.adjustImportsOptionsPath(), - relocatedFilesPath, - }, - ]; + private adjustImportsPlugin(resolverConfig: ResolverConfig): PluginItem { + let pluginConfig: AdjustImportsOptions = { + extraImports: resolverConfig.extraImports, + }; + return [require.resolve('./babel-plugin-adjust-imports'), pluginConfig]; } private insertEmberApp( @@ -703,9 +693,10 @@ export class AppBuilder { .reverse() .forEach(a => a.differ.update()); return this.appDiffers.map(a => { - return Object.assign({}, a.engine, { + return { + ...a.engine, appFiles: new AppFiles(a.differ, this.resolvableExtensionsPattern, this.adapter.podModulePrefix()), - }); + }; }); } @@ -894,8 +885,6 @@ export class AppBuilder { let assets = this.gatherAssets(inputPaths); let finalAssets = await this.updateAssets(assets, appFiles, emberENV); - let babelConfig = this.babelConfig(appFiles); - this.addBabelConfig(babelConfig); let assetPaths = assets.map(asset => asset.relativePath); @@ -919,11 +908,10 @@ export class AppBuilder { assets: assetPaths, babel: { filename: '_babel_config_.js', - isParallelSafe: babelConfig.isParallelSafe, + isParallelSafe: true, // TODO majorVersion: this.adapter.babelMajorVersion(), fileFilter: '_babel_filter_.js', }, - 'resolvable-extensions': this.adapter.resolverConfig().resolvableExtensions, 'root-url': this.adapter.rootURL(), }; @@ -933,6 +921,11 @@ export class AppBuilder { let pkg = this.combinePackageJSON(meta); writeFileSync(join(this.root, 'package.json'), JSON.stringify(pkg, null, 2), 'utf8'); + + let resolverConfig = this.adapter.resolverConfig(appFiles); + this.addResolverConfig(resolverConfig); + let babelConfig = this.babelConfig(resolverConfig); + this.addBabelConfig(babelConfig); } private combinePackageJSON(meta: AppMeta): object { @@ -947,7 +940,7 @@ export class AppBuilder { return combinePackageJSON(...pkgLayers); } - private etcOptions(): EtcOptions { + private etcOptions(resolverConfig: ResolverConfig): EtcOptions { let transforms = this.adapter.htmlbarsPlugins(); let { plugins: macroPlugins, setConfig } = MacrosConfig.transforms(); @@ -956,7 +949,7 @@ export class AppBuilder { transforms.push(macroPlugin as any); } - let transform = this.adapter.resolverTransform(); + let transform = this.adapter.resolverTransform(resolverConfig); if (transform) { transforms.push(transform); } @@ -1004,6 +997,10 @@ export class AppBuilder { ); } + private addResolverConfig(config: ResolverConfig) { + outputJSONSync(join(this.root, '.embroider', 'resolver.json'), config); + } + private shouldSplitRoute(routeName: string) { return ( !this.options.splitAtRoutes || diff --git a/packages/core/src/babel-plugin-adjust-imports.ts b/packages/core/src/babel-plugin-adjust-imports.ts index 7a4a3cde8..faf0d8f4e 100644 --- a/packages/core/src/babel-plugin-adjust-imports.ts +++ b/packages/core/src/babel-plugin-adjust-imports.ts @@ -8,72 +8,19 @@ import { Options as ModuleResolveroptions } from './module-resolver'; export type Options = Pick; interface State { - opts: Options | DeflatedOptions; -} - -export interface DeflatedOptions { - adjustImportsOptionsPath: string; - relocatedFilesPath: string; + opts: Options; } type BabelTypes = typeof t; -type DefineExpressionPath = NodePath & { - node: t.CallExpression & { - arguments: [t.StringLiteral, t.ArrayExpression, Function]; - }; -}; - -export function isImportSyncExpression(t: BabelTypes, path: NodePath) { - if ( - !path || - !path.isCallExpression() || - path.node.callee.type !== 'Identifier' || - !path.get('callee').referencesImport('@embroider/macros', 'importSync') - ) { - return false; - } - - const args = path.node.arguments; - return Array.isArray(args) && args.length === 1 && t.isStringLiteral(args[0]); -} - -export function isDynamicImportExpression(t: BabelTypes, path: NodePath) { - if (!path || !path.isCallExpression() || path.node.callee.type !== 'Import') { - return false; - } - - const args = path.node.arguments; - return Array.isArray(args) && args.length === 1 && t.isStringLiteral(args[0]); -} - -export function isDefineExpression(t: BabelTypes, path: NodePath): path is DefineExpressionPath { - // should we allow nested defines, or stop at the top level? - if (!path.isCallExpression() || path.node.callee.type !== 'Identifier' || path.node.callee.name !== 'define') { - return false; - } - - const args = path.node.arguments; - - // only match define with 3 arguments define(name: string, deps: string[], cb: Function); - return ( - Array.isArray(args) && - args.length === 3 && - t.isStringLiteral(args[0]) && - t.isArrayExpression(args[1]) && - t.isFunction(args[2]) - ); -} - export default function main(babel: typeof Babel) { let t = babel.types; return { visitor: { Program: { enter(path: NodePath, state: State) { - let opts = ensureOpts(state); let adder = new ImportUtil(t, path); - addExtraImports(adder, t, path, opts.extraImports); + addExtraImports(adder, t, path, state.opts.extraImports); }, }, }, @@ -110,12 +57,3 @@ function amdDefine(t: BabelTypes, adder: ImportUtil, path: NodePath, ]) ); } - -function ensureOpts(state: State): Options { - let { opts } = state; - if ('adjustImportsOptionsPath' in opts) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - return (state.opts = { ...require(opts.adjustImportsOptionsPath), ...require(opts.relocatedFilesPath) }); - } - return opts; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 51946e245..f1bc700d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,7 +17,15 @@ export { compile as jsHandlebarsCompile } from './js-handlebars'; export { AppAdapter, AppBuilder, EmberENV } from './app'; export { todo, unsupported, warn, debug, expectWarning, throwOnWarnings } from './messages'; export { mangledEngineRoot } from './engine-mangler'; -export { Resolver, Options as ResolverOptions, Resolution } from './module-resolver'; +export { + Resolver, + Options as ResolverOptions, + ModuleRequest, + Resolution, + ResolverFunction, + SyncResolverFunction, +} from './module-resolver'; +export type { Engine } from './app-files'; // this is reexported because we already make users manage a peerDep from some // other packages (like embroider/webpack and @embroider/compat diff --git a/packages/core/src/module-resolver.ts b/packages/core/src/module-resolver.ts index f181b812f..417b7d2e6 100644 --- a/packages/core/src/module-resolver.ts +++ b/packages/core/src/module-resolver.ts @@ -4,6 +4,9 @@ import { PackageCache, Package, V2Package, explicitRelative } from '@embroider/s import { compile } from './js-handlebars'; import makeDebug from 'debug'; import assertNever from 'assert-never'; +import resolveModule from 'resolve'; + +const debug = makeDebug('embroider:resolver'); export interface Options { renamePackages: { @@ -17,7 +20,6 @@ export interface Options { target: string; runtimeName?: string; }[]; - externalsDir: string; activeAddons: { [packageName: string]: string; }; @@ -28,53 +30,169 @@ export interface Options { const externalPrefix = '/@embroider/external/'; -export type Resolution = - | { result: 'continue' } - | { result: 'alias'; specifier: string; fromFile?: string } - | { result: 'rehome'; fromFile: string } - | { result: 'virtual'; filename: string }; +export interface ModuleRequest { + specifier: string; + fromFile: string; + isVirtual: boolean; + alias(newSpecifier: string, newFromFile?: string): this; + rehome(newFromFile: string): this; + virtualize(virtualFilename: string): this; +} + +class NodeModuleRequest implements ModuleRequest { + constructor(readonly specifier: string, readonly fromFile: string, readonly isVirtual = false) {} + alias(specifier: string): this { + return new NodeModuleRequest(specifier, this.fromFile) as this; + } + rehome(fromFile: string): this { + return new NodeModuleRequest(this.specifier, fromFile) as this; + } + virtualize(filename: string) { + return new NodeModuleRequest(filename, this.fromFile, true) as this; + } +} + +// This is generic because different build systems have different ways of +// representing a found module, and we just pass those values through. +export type Resolution = { type: 'found'; result: T } | { type: 'not_found'; err: E }; + +export type ResolverFunction = ( + request: R +) => Promise; + +export type SyncResolverFunction = ( + request: R +) => Res; export class Resolver { - // Given a filename that was returned with result === 'virtual', this produces - // the corresponding contents. It's a static, stateless function because we - // recognize that that process that did resolution might not be the same one - // that loads the content. - static virtualContent(filename: string): string | undefined { + // Given a filename that was passed to your ModuleRequest's `virtualize()`, + // this produces the corresponding contents. It's a static, stateless function + // because we recognize that that process that did resolution might not be the + // same one that loads the content. + static virtualContent(filename: string): string { if (filename.startsWith(externalPrefix)) { return externalShim({ moduleName: filename.slice(externalPrefix.length) }); } - return undefined; + throw new Error(`not an @embroider/core virtual file: ${filename}`); } constructor(private options: Options) {} - beforeResolve(specifier: string, fromFile: string): Resolution { - let resolution = this.internalBeforeResolve(specifier, fromFile); - debug('[%s] %s %s => %r', 'before', specifier, fromFile, resolution); - return resolution; - } - - private internalBeforeResolve(specifier: string, fromFile: string): Resolution { - if (specifier === '@embroider/macros') { + beforeResolve(request: R): R { + if (request.specifier === '@embroider/macros') { // the macros package is always handled directly within babel (not // necessarily as a real resolvable package), so we should not mess with it. // It might not get compiled away until *after* our plugin has run, which is // why we need to know about it. - return { result: 'continue' }; + return request; + } + + return this.preHandleExternal(this.handleRenaming(request)); + } + + // This encapsulates the whole resolving process. Given a `defaultResolve` + // that calls your build system's normal module resolver, this does both pre- + // and post-resolution adjustments as needed to implement our compatibility + // rules. + // + // Depending on the plugin architecture you're working in, it may be easier to + // call beforeResolve and fallbackResolve directly, in which case matching the + // details of the recursion to what this method does are your responsibility. + async resolve( + request: Req, + defaultResolve: ResolverFunction + ): Promise { + let gen = this.internalResolve>(request, defaultResolve); + let out = gen.next(); + while (!out.done) { + out = gen.next(await out.value); + } + return out.value; + } + + // synchronous alternative to resolve() above. Because our own internals are + // all synchronous, you can use this if your defaultResolve function is + // synchronous. At present, we need this for the case where we are compiling + // non-strict templates and doing component resolutions inside the template + // compiler inside babel, which is a synchronous context. + resolveSync( + request: Req, + defaultResolve: SyncResolverFunction + ): Res { + let gen = this.internalResolve(request, defaultResolve); + let out = gen.next(); + while (!out.done) { + out = gen.next(out.value); } + return out.value; + } - let maybeRenamed = this.handleRenaming(specifier, fromFile); - let resolution = this.preHandleExternal(maybeRenamed, fromFile); - if (resolution.result === 'continue' && maybeRenamed !== specifier) { - return { result: 'alias', specifier: maybeRenamed }; + // Our core implementation is a generator so it can power both resolve() and + // resolveSync() + private *internalResolve( + request: Req, + defaultResolve: (req: Req) => Yielded + ): Generator { + request = this.beforeResolve(request); + let resolution = yield defaultResolve(request); + + switch (resolution.type) { + case 'found': + return resolution; + case 'not_found': + break; + default: + throw assertNever(resolution); + } + let nextRequest = this.fallbackResolve(request); + if (nextRequest === request) { + // no additional fallback is available. + return resolution; + } + if (nextRequest.isVirtual) { + // virtual requests are terminal, there is no more beforeResolve or + // fallbackResolve around them. The defaultResolve is expected to know how + // to implement them. + return yield defaultResolve(nextRequest); } - return resolution; + return yield* this.internalResolve(nextRequest, defaultResolve); } - fallbackResolve(specifier: string, fromFile: string): Resolution { - let resolution = this.postHandleExternal(specifier, fromFile); - debug('[%s] %s %s => %r', 'fallback', specifier, fromFile, resolution); - return resolution; + // Use standard NodeJS resolving, with our required compatibility rules on + // top. This is a convenience method for calling resolveSync with the + // defaultResolve already configured to be "do the normal node thing". + nodeResolve( + specifier: string, + fromFile: string + ): { type: 'virtual'; content: string } | { type: 'real'; filename: string } | { type: 'not_found'; err: Error } { + let resolution = this.resolveSync(new NodeModuleRequest(specifier, fromFile), request => { + if (request.isVirtual) { + return { + type: 'found', + result: { type: 'virtual' as 'virtual', content: Resolver.virtualContent(request.specifier) }, + }; + } + try { + let filename = resolveModule.sync(request.specifier, { + basedir: dirname(request.fromFile), + extensions: this.options.resolvableExtensions, + }); + return { type: 'found', result: { type: 'real' as 'real', filename } }; + } catch (err) { + if (err.code !== 'MODULE_NOT_FOUND') { + throw err; + } + return { type: 'not_found', err }; + } + }); + switch (resolution.type) { + case 'not_found': + return resolution; + case 'found': + return resolution.result; + default: + throw assertNever(resolution); + } } private owningPackage(fromFile: string): Package | undefined { @@ -92,33 +210,34 @@ export class Resolver { } } - private handleRenaming(specifier: string, fromFile: string) { - let packageName = getPackageName(specifier); + private handleRenaming(request: R): R { + let packageName = getPackageName(request.specifier); if (!packageName) { - return specifier; + return request; } for (let [candidate, replacement] of Object.entries(this.options.renameModules)) { - if (candidate === specifier) { - return replacement; + if (candidate === request.specifier) { + debug(`[beforeResolve] aliased ${request.specifier} in ${request.fromFile} to ${replacement}`); + return request.alias(replacement); } for (let extension of this.options.resolvableExtensions) { - if (candidate === specifier + '/index' + extension) { - return replacement; + if (candidate === request.specifier + '/index' + extension) { + return request.alias(replacement); } - if (candidate === specifier + extension) { - return replacement; + if (candidate === request.specifier + extension) { + return request.alias(replacement); } } } if (this.options.renamePackages[packageName]) { - return specifier.replace(packageName, this.options.renamePackages[packageName]); + return request.alias(request.specifier.replace(packageName, this.options.renamePackages[packageName])); } - let pkg = this.owningPackage(fromFile); + let pkg = this.owningPackage(request.fromFile); if (!pkg || !pkg.isV2Ember()) { - return specifier; + return request; } if (pkg.meta['auto-upgraded'] && pkg.name === packageName) { @@ -126,17 +245,17 @@ export class Resolver { // packages get this help, v2 packages are natively supposed to make their // own modules resolvable, and we want to push them all to do that // correctly. - return this.resolveWithinPackage(specifier, pkg); + return request.alias(this.resolveWithinPackage(request.specifier, pkg)); } - let originalPkg = this.originalPackage(fromFile); + let originalPkg = this.originalPackage(request.fromFile); if (originalPkg && pkg.meta['auto-upgraded'] && originalPkg.name === packageName) { // A file that was relocated out of a package is importing that package's // name, it should find its own original copy. - return this.resolveWithinPackage(specifier, originalPkg); + return request.alias(this.resolveWithinPackage(request.specifier, originalPkg)); } - return specifier; + return request; } private resolveWithinPackage(specifier: string, pkg: Package): string { @@ -148,10 +267,11 @@ export class Resolver { return specifier.replace(pkg.name, pkg.root); } - private preHandleExternal(specifier: string, fromFile: string): Resolution { + private preHandleExternal(request: R): R { + let { specifier, fromFile } = request; let pkg = this.owningPackage(fromFile); if (!pkg || !pkg.isV2Ember()) { - return { result: 'continue' }; + return request; } let packageName = getPackageName(specifier); @@ -165,16 +285,16 @@ export class Resolver { let packageRelativeSpecifier = explicitRelative(pkg.root, absoluteSpecifier); if (isExplicitlyExternal(packageRelativeSpecifier, pkg)) { let publicSpecifier = absoluteSpecifier.replace(pkg.root, pkg.name); - return external(publicSpecifier); + return external('beforeResolve', request, publicSpecifier); } else { - return { result: 'continue' }; + return request; } } // absolute package imports can also be explicitly external based on their // full specifier name if (isExplicitlyExternal(specifier, pkg)) { - return external(specifier); + return external('beforeResolve', request, specifier); } if (!pkg.meta['auto-upgraded'] && emberVirtualPeerDeps.has(packageName)) { @@ -193,10 +313,9 @@ export class Resolver { if (!this.options.activeAddons[packageName]) { throw new Error(`${pkg.name} is trying to import the app's ${packageName} package, but it seems to be missing`); } - return { - result: 'rehome', - fromFile: resolve(this.options.appRoot, 'package.json'), - }; + let newHome = resolve(this.options.appRoot, 'package.json'); + debug(`[beforeResolve] rehomed ${request.specifier} from ${request.fromFile} to ${newHome}`); + return request.rehome(newHome); } if (pkg.meta['auto-upgraded'] && !pkg.hasDependency('ember-auto-import')) { @@ -205,7 +324,7 @@ export class Resolver { if (!dep.isEmberPackage()) { // classic ember addons can only import non-ember dependencies if they // have ember-auto-import. - return external(specifier); + return external('beforeResolve', request, specifier); } } catch (err) { if (err.code !== 'MODULE_NOT_FOUND') { @@ -235,29 +354,27 @@ export class Resolver { } } } - return { result: 'continue' }; + return request; } - private postHandleExternal(specifier: string, fromFile: string): Resolution { + fallbackResolve(request: R): R { + let { specifier, fromFile } = request; let pkg = this.owningPackage(fromFile); if (!pkg || !pkg.isV2Ember()) { - return { result: 'continue' }; + return request; } let packageName = getPackageName(specifier); if (!packageName) { // this is a relative import, we have nothing more to for it. - return { result: 'continue' }; + return request; } let originalPkg = this.originalPackage(fromFile); if (originalPkg) { // we didn't find it from the original package, so try from the relocated // package - return { - result: 'rehome', - fromFile: resolve(originalPkg.root, 'package.json'), - }; + return request.rehome(resolve(originalPkg.root, 'package.json')); } // auto-upgraded packages can fall back to the set of known active addons @@ -265,13 +382,12 @@ export class Resolver { // v2 packages can fall back to the set of known active addons only to find // themselves (which is needed due to app tree merging) if ((pkg.meta['auto-upgraded'] || packageName === pkg.name) && this.options.activeAddons[packageName]) { - return { - result: 'alias', - specifier: this.resolveWithinPackage( + return request.alias( + this.resolveWithinPackage( specifier, PackageCache.shared('embroider-stage3', this.options.appRoot).get(this.options.activeAddons[packageName]) - ), - }; + ) + ); } if (pkg.meta['auto-upgraded']) { @@ -279,19 +395,19 @@ export class Resolver { // runtime. Native v2 packages can only get this behavior in the // isExplicitlyExternal case above because they need to explicitly ask for // externals. - return external(specifier); + return external('fallbackResolve', request, specifier); } else { // native v2 packages don't automatically externalize *everything* the way // auto-upgraded packages do, but they still externalize known and approved // ember virtual packages (like @ember/component) if (emberVirtualPackages.has(packageName)) { - return external(specifier); + return external('fallbackResolve', request, specifier); } } // this is falling through with the original specifier which was // non-resolvable, which will presumably cause a static build error in stage3. - return { result: 'continue' }; + return request; } } @@ -320,11 +436,10 @@ function reliablyResolvable(pkg: V2Package, packageName: string) { return false; } -function external(specifier: string): Resolution { - return { - result: 'virtual', - filename: externalPrefix + specifier, - }; +function external(label: string, request: R, specifier: string): R { + let filename = externalPrefix + specifier; + debug(`[${label}] virtualized ${request.specifier} as ${filename}`); + return request.virtualize(filename); } const externalShim = compile(` @@ -349,23 +464,3 @@ if (m.default && !m.__esModule) { } module.exports = m; `) as (params: { moduleName: string }) => string; - -const debug = makeDebug('embroider:resolver'); -makeDebug.formatters.r = (r: Resolution) => { - switch (r.result) { - case 'alias': - if (r.fromFile) { - return `alias:${r.specifier} from ${r.fromFile}`; - } else { - return `alias:${r.specifier}`; - } - case 'rehome': - return `rehome:${r.fromFile}`; - case 'continue': - return 'continue'; - case 'virtual': - return 'virtual'; - default: - throw assertNever(r); - } -}; diff --git a/packages/shared-internals/src/metadata.ts b/packages/shared-internals/src/metadata.ts index 1075c3333..c3bd5def5 100644 --- a/packages/shared-internals/src/metadata.ts +++ b/packages/shared-internals/src/metadata.ts @@ -15,7 +15,6 @@ export interface AppMeta { majorVersion: 7; fileFilter: string; }; - 'resolvable-extensions': string[]; 'root-url': string; version: 2; } diff --git a/packages/webpack/src/ember-webpack.ts b/packages/webpack/src/ember-webpack.ts index 2788b79b8..582ba4104 100644 --- a/packages/webpack/src/ember-webpack.ts +++ b/packages/webpack/src/ember-webpack.ts @@ -19,6 +19,7 @@ import { getAppMeta, getPackagerCacheDir, getOrCreate, + ResolverOptions, } from '@embroider/core'; import { tmpdir } from '@embroider/shared-internals'; import webpack, { Configuration, RuleSetUseItem, WebpackPluginInstance } from 'webpack'; @@ -51,7 +52,7 @@ interface AppInfo { babel: AppMeta['babel']; rootURL: AppMeta['root-url']; publicAssetURL: string; - resolvableExtensions: AppMeta['resolvable-extensions']; + resolverConfig: ResolverOptions; packageName: string; } @@ -167,7 +168,6 @@ const Webpack: PackagerConstructor = class Webpack implements Packager let meta = getAppMeta(this.pathToVanillaApp); let rootURL = meta['ember-addon']['root-url']; let babel = meta['ember-addon']['babel']; - let resolvableExtensions = meta['ember-addon']['resolvable-extensions']; let entrypoints = []; let otherAssets = []; let publicAssetURL = this.publicAssetURL || rootURL; @@ -180,11 +180,13 @@ const Webpack: PackagerConstructor = class Webpack implements Packager } } - return { entrypoints, otherAssets, babel, rootURL, resolvableExtensions, publicAssetURL, packageName: meta.name }; + let resolverConfig: EmbroiderPluginOptions = readJSONSync(join(this.pathToVanillaApp, '.embroider/resolver.json')); + + return { entrypoints, otherAssets, babel, rootURL, resolverConfig, publicAssetURL, packageName: meta.name }; } private configureWebpack(appInfo: AppInfo, variant: Variant, variantIndex: number): Configuration { - const { entrypoints, babel, resolvableExtensions, publicAssetURL, packageName } = appInfo; + const { entrypoints, babel, publicAssetURL, packageName, resolverConfig } = appInfo; let entry: { [name: string]: string } = {}; for (let entrypoint of entrypoints) { @@ -195,11 +197,6 @@ const Webpack: PackagerConstructor = class Webpack implements Packager let { plugins: stylePlugins, loaders: styleLoaders } = this.setupStyleConfig(variant); - let resolverConfig: EmbroiderPluginOptions = { - ...readJSONSync(join(this.pathToVanillaApp, '_adjust_imports.json')), - ...readJSONSync(join(this.pathToVanillaApp, '_relocated_files.json')), - }; - return { mode: variant.optimizeForProduction ? 'production' : 'development', context: this.pathToVanillaApp, @@ -275,7 +272,7 @@ const Webpack: PackagerConstructor = class Webpack implements Packager }, }, resolve: { - extensions: resolvableExtensions, + extensions: resolverConfig.resolvableExtensions, }, resolveLoader: { alias: { diff --git a/packages/webpack/src/virtual-loader.ts b/packages/webpack/src/virtual-loader.ts index e3a3647eb..0b8c38ec2 100644 --- a/packages/webpack/src/virtual-loader.ts +++ b/packages/webpack/src/virtual-loader.ts @@ -4,10 +4,7 @@ import { LoaderContext } from 'webpack'; export default function virtualLoader(this: LoaderContext) { let filename = this.loaders[this.loaderIndex].options; if (typeof filename === 'string') { - let content = Resolver.virtualContent(filename); - if (content) { - return content; - } + return Resolver.virtualContent(filename); } throw new Error(`@embroider/webpack/src/virtual-loader received unexpected request: ${filename}`); } diff --git a/packages/webpack/src/webpack-resolver-plugin.ts b/packages/webpack/src/webpack-resolver-plugin.ts index 62a3e5bc4..9e838d822 100644 --- a/packages/webpack/src/webpack-resolver-plugin.ts +++ b/packages/webpack/src/webpack-resolver-plugin.ts @@ -2,6 +2,8 @@ import { dirname, resolve } from 'path'; import { Resolver as EmbroiderResolver, ResolverOptions as EmbroiderResolverOptions, + ModuleRequest, + ResolverFunction, Resolution, } from '@embroider/core'; import type { Compiler, Module } from 'webpack'; @@ -31,86 +33,123 @@ export class EmbroiderPlugin { } } - #handle(resolution: Resolution, state: Request) { - switch (resolution.result) { - case 'alias': - state.request = resolution.specifier; - if (resolution.fromFile) { - state.contextInfo.issuer = resolution.fromFile; - state.context = dirname(resolution.fromFile); - } - break; - case 'rehome': - state.contextInfo.issuer = resolution.fromFile; - state.context = dirname(resolution.fromFile); - break; - case 'virtual': - state.request = `${virtualLoaderName}?${resolution.filename}!`; - break; - case 'continue': - break; - default: - throw assertNever(resolution); - } - } + apply(compiler: Compiler) { + this.#addLoaderAlias(compiler, virtualLoaderName, resolve(__dirname, './virtual-loader')); - #resolve(defaultResolve: (state: unknown, callback: CB) => void, state: unknown, callback: CB) { - if (isRelevantRequest(state)) { - let resolution = this.#resolver.beforeResolve(state.request, state.contextInfo.issuer); - if (resolution.result !== 'continue') { - this.#handle(resolution, state); - } - } + compiler.hooks.normalModuleFactory.tap('@embroider/webpack', nmf => { + let defaultResolve = getDefaultResolveHook(nmf.hooks.resolve.taps); + let adaptedResolve = getAdaptedResolve(defaultResolve); - defaultResolve(state, (err, result) => { - if (err && isRelevantRequest(state)) { - let resolution = this.#resolver.fallbackResolve(state.request, state.contextInfo.issuer); - if (resolution.result === 'continue') { - callback(err); - } else { - this.#handle(resolution, state); - this.#resolve(defaultResolve, state, callback); + nmf.hooks.resolve.tapAsync({ name: '@embroider/webpack', stage: 50 }, (state: unknown, callback: CB) => { + let request = WebpackModuleRequest.from(state); + if (!request) { + defaultResolve(state, callback); + return; } - } else { - callback(null, result); - } + + this.#resolver.resolve(request, adaptedResolve).then( + resolution => { + switch (resolution.type) { + case 'not_found': + callback(resolution.err); + break; + case 'found': + callback(null, resolution.result); + break; + default: + throw assertNever(resolution); + } + }, + err => callback(err) + ); + }); }); } +} - apply(compiler: Compiler) { - this.#addLoaderAlias(compiler, virtualLoaderName, resolve(__dirname, './virtual-loader')); +type CB = (err: null | Error, result?: Module | undefined) => void; +type DefaultResolve = (state: unknown, callback: CB) => void; - compiler.hooks.normalModuleFactory.tap('my-experiment', nmf => { - // Despite being absolutely riddled with way-too-powerful tap points, - // webpack still doesn't succeed in making it possible to provide a - // fallback to the default resolve hook in the NormalModuleFactory. So - // instead we will find the default behavior and call it from our own tap, - // giving us a chance to handle its failures. - let { fn: defaultResolve } = nmf.hooks.resolve.taps.find(t => t.name === 'NormalModuleFactory')!; - - nmf.hooks.resolve.tapAsync({ name: 'my-experiment', stage: 50 }, (state: unknown, callback: CB) => - this.#resolve(defaultResolve as any, state, callback) - ); - }); - } +// Despite being absolutely riddled with way-too-powerful tap points, +// webpack still doesn't succeed in making it possible to provide a +// fallback to the default resolve hook in the NormalModuleFactory. So +// instead we will find the default behavior and call it from our own tap, +// giving us a chance to handle its failures. +function getDefaultResolveHook(taps: { name: string; fn: Function }[]): DefaultResolve { + let { fn } = taps.find(t => t.name === 'NormalModuleFactory')!; + return fn as DefaultResolve; } -interface Request { - request: string; - context: string; - contextInfo: { - issuer: string; +// This converts the raw function we got out of webpack into the right interface +// for use by @embroider/core's resolver. +function getAdaptedResolve( + defaultResolve: DefaultResolve +): ResolverFunction> { + return function (request: WebpackModuleRequest): Promise> { + return new Promise(resolve => { + defaultResolve(request.state, (err, value) => { + if (err) { + // unfortunately webpack doesn't let us distinguish between Not Found + // and other unexpected exceptions here. + resolve({ type: 'not_found', err }); + } else { + resolve({ type: 'found', result: value! }); + } + }); + }); }; } -type CB = (err: Error | null, result?: Module) => void; +class WebpackModuleRequest implements ModuleRequest { + specifier: string; + fromFile: string; -function isRelevantRequest(request: any): request is Request { - return ( - typeof request.request === 'string' && - typeof request.context === 'string' && - typeof request.contextInfo?.issuer === 'string' && - request.contextInfo.issuer !== '' && - !request.request.startsWith(virtualLoaderName) // prevents recursion on requests we have already sent to our virtual loader - ); + static from(state: any): WebpackModuleRequest | undefined { + if ( + typeof state.request === 'string' && + typeof state.context === 'string' && + typeof state.contextInfo?.issuer === 'string' && + state.contextInfo.issuer !== '' && + !state.request.startsWith(virtualLoaderName) // prevents recursion on requests we have already sent to our virtual loader + ) { + return new WebpackModuleRequest(state); + } + } + + constructor( + public state: { + request: string; + context: string; + contextInfo: { + issuer: string; + }; + }, + public isVirtual = false + ) { + // these get copied here because we mutate the underlying state as we + // convert one request into the next, and it seems better for debuggability + // if the fields on the previous request don't change when you make a new + // one (although it is true that only the newest one has a a valid `state` + // that can actually be handed back to webpack) + this.specifier = state.request; + this.fromFile = state.contextInfo.issuer; + } + + alias(newSpecifier: string, newFromFile?: string) { + this.state.request = newSpecifier; + if (newFromFile) { + this.state.contextInfo.issuer = newFromFile; + this.state.context = dirname(newFromFile); + } + return new WebpackModuleRequest(this.state) as this; + } + rehome(newFromFile: string) { + this.state.contextInfo.issuer = newFromFile; + this.state.context = dirname(newFromFile); + return new WebpackModuleRequest(this.state) as this; + } + virtualize(filename: string) { + this.state.request = `${virtualLoaderName}?${filename}!`; + return new WebpackModuleRequest(this.state, true) as this; + } }