diff --git a/packages/core/src/module-resolver.ts b/packages/core/src/module-resolver.ts index 3daa1a81e..f181b812f 100644 --- a/packages/core/src/module-resolver.ts +++ b/packages/core/src/module-resolver.ts @@ -2,6 +2,8 @@ import { emberVirtualPackages, emberVirtualPeerDeps, packageName as getPackageNa import { dirname, resolve } from 'path'; import { PackageCache, Package, V2Package, explicitRelative } from '@embroider/shared-internals'; import { compile } from './js-handlebars'; +import makeDebug from 'debug'; +import assertNever from 'assert-never'; export interface Options { renamePackages: { @@ -24,16 +26,35 @@ export interface Options { appRoot: string; } +const externalPrefix = '/@embroider/external/'; + export type Resolution = | { result: 'continue' } | { result: 'alias'; specifier: string; fromFile?: string } | { result: 'rehome'; fromFile: string } - | { result: 'virtual'; filename: string; content: string }; + | { result: 'virtual'; filename: string }; 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 { + if (filename.startsWith(externalPrefix)) { + return externalShim({ moduleName: filename.slice(externalPrefix.length) }); + } + return undefined; + } + 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') { // the macros package is always handled directly within babel (not // necessarily as a real resolvable package), so we should not mess with it. @@ -51,7 +72,9 @@ export class Resolver { } fallbackResolve(specifier: string, fromFile: string): Resolution { - return this.postHandleExternal(specifier, fromFile); + let resolution = this.postHandleExternal(specifier, fromFile); + debug('[%s] %s %s => %r', 'fallback', specifier, fromFile, resolution); + return resolution; } private owningPackage(fromFile: string): Package | undefined { @@ -103,21 +126,28 @@ 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 specifier.replace(packageName, pkg.root); + return this.resolveWithinPackage(specifier, pkg); } let originalPkg = this.originalPackage(fromFile); if (originalPkg && pkg.meta['auto-upgraded'] && originalPkg.name === packageName) { - // a file that was relocated into a package does a self-import of that - // package's name. This can happen when an addon (like ember-cli-mirage) - // emits files from its own treeForApp that contain imports of the app's own - // fully qualified name. - return specifier.replace(packageName, originalPkg.root); + // 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 specifier; } + private resolveWithinPackage(specifier: string, pkg: Package): string { + if ('exports' in pkg.packageJSON) { + // this is the easy case -- a package that uses exports can safely resolve + // its own name + return require.resolve(specifier, { paths: [pkg.root] }); + } + return specifier.replace(pkg.name, pkg.root); + } + private preHandleExternal(specifier: string, fromFile: string): Resolution { let pkg = this.owningPackage(fromFile); if (!pkg || !pkg.isV2Ember()) { @@ -237,7 +267,10 @@ export class Resolver { if ((pkg.meta['auto-upgraded'] || packageName === pkg.name) && this.options.activeAddons[packageName]) { return { result: 'alias', - specifier: specifier.replace(packageName, this.options.activeAddons[packageName]), + specifier: this.resolveWithinPackage( + specifier, + PackageCache.shared('embroider-stage3', this.options.appRoot).get(this.options.activeAddons[packageName]) + ), }; } @@ -290,8 +323,7 @@ function reliablyResolvable(pkg: V2Package, packageName: string) { function external(specifier: string): Resolution { return { result: 'virtual', - filename: specifier, - content: externalShim({ moduleName: specifier }), + filename: externalPrefix + specifier, }; } @@ -317,3 +349,23 @@ 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/webpack/package.json b/packages/webpack/package.json index ab4e0b480..df88ed60a 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -39,8 +39,7 @@ "style-loader": "^2.0.0", "supports-color": "^8.1.0", "terser": "^5.7.0", - "thread-loader": "^3.0.4", - "webpack-virtual-modules": "^0.5.0" + "thread-loader": "^3.0.4" }, "devDependencies": { "@types/csso": "^3.5.1", diff --git a/packages/webpack/src/virtual-loader.ts b/packages/webpack/src/virtual-loader.ts new file mode 100644 index 000000000..e3a3647eb --- /dev/null +++ b/packages/webpack/src/virtual-loader.ts @@ -0,0 +1,13 @@ +import { Resolver } from '@embroider/core'; +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; + } + } + 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 115307946..62a3e5bc4 100644 --- a/packages/webpack/src/webpack-resolver-plugin.ts +++ b/packages/webpack/src/webpack-resolver-plugin.ts @@ -1,169 +1,116 @@ -import { dirname } from 'path'; -import VirtualModulesPlugin from 'webpack-virtual-modules'; +import { dirname, resolve } from 'path'; import { Resolver as EmbroiderResolver, ResolverOptions as EmbroiderResolverOptions, Resolution, } from '@embroider/core'; -import type { Compiler, Resolver as WebpackResolver } from 'webpack'; +import type { Compiler, Module } from 'webpack'; import assertNever from 'assert-never'; export { EmbroiderResolverOptions as Options }; -export class EmbroiderPlugin { - constructor(private opts: EmbroiderResolverOptions) {} - apply(compiler: Compiler) { - if (!compiler.options.resolve.plugins) { - compiler.options.resolve.plugins = []; - } - - let vfs = compiler.options.plugins.find((i: unknown) => i instanceof VirtualModulesPlugin) as - | VirtualModulesPlugin - | undefined; - - if (!vfs) { - vfs = new VirtualModulesPlugin(); - compiler.options.plugins.push(vfs); - } - - let resolverPlugin = new ResolverPlugin(vfs, this.opts); - compiler.options.resolve.plugins.push(resolverPlugin); - } -} +const virtualLoaderName = '@embroider/webpack/src/virtual-loader'; -class ResolverPlugin { - private resolver: EmbroiderResolver; +export class EmbroiderPlugin { + #resolver: EmbroiderResolver; - constructor(private vfs: VirtualModulesPlugin, opts: EmbroiderResolverOptions) { - this.resolver = new EmbroiderResolver(opts); + constructor(opts: EmbroiderResolverOptions) { + this.#resolver = new EmbroiderResolver(opts); } - #resolve( - resolution: Resolution, - resolver: WebpackResolver, - request: Request, - context: unknown, - callback: (err?: Error | null, result?: any) => void - ) { - if (resolution.result === 'virtual') { - this.vfs.writeModule(`/@embroider/externals/${resolution.filename}`, resolution.content); - resolution = { - result: 'alias', - specifier: `/@embroider/externals/${resolution.filename}`, + #addLoaderAlias(compiler: Compiler, name: string, alias: string) { + let { resolveLoader } = compiler.options; + if (Array.isArray(resolveLoader.alias)) { + resolveLoader.alias.push({ name, alias }); + } else if (resolveLoader.alias) { + resolveLoader.alias[name] = alias; + } else { + resolveLoader.alias = { + [name]: alias, }; } + } + #handle(resolution: Resolution, state: Request) { switch (resolution.result) { - case 'alias': { - let newRequest = { - request: resolution.specifier, - path: resolution.fromFile ? dirname(resolution.fromFile) : request.path, - fullySpecified: false, - context: { - issuer: resolution.fromFile ?? request.context.issuer, - }, - }; - resolver.doResolve( - resolver.ensureHook('internal-resolve'), - newRequest, - '@embroider/webpack', - context, - wrapCallback(callback) - ); - return; - } - case 'rehome': { - let newRequest = { - request: request.request, - path: dirname(resolution.fromFile), - fullySpecified: false, - context: { - issuer: resolution.fromFile, - }, - }; - resolver.doResolve( - resolver.ensureHook('internal-resolve'), - newRequest, - '@embroider/webpack', - context, - wrapCallback(callback) - ); - return; - } + 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': - callback(); - return; + break; default: throw assertNever(resolution); } } - apply(resolver: WebpackResolver) { - // raw-resolve -> internal-resolve is the same place in the pipeline that - // webpack's built-in `resolve.alias` takes effect. It's supposed to take - // precedence over other resolving decisions. - resolver.getHook('raw-resolve').tapAsync('my-resolver-plugin', async (request, context, callback) => { - try { - if (!isRelevantRequest(request) || request.request.startsWith('@embroider/externals/')) { - callback(); - return; - } - let result = this.resolver.beforeResolve(request.request, request.context.issuer); - this.#resolve(result, resolver, request, context, callback); - } catch (err) { - callback(err); + #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); } - }); + } - // described-resolve -> internal-resolve is the same place in the pipeline - // that webpack's built-in `resolve.fallback` takes effect. It's supposed to - // only run when the rest of resolving fails to find something. - resolver.getHook('described-resolve').tapAsync( - // we need to set the stage here because otherwise we end up before the - // built-in NextPlugin. Instead we want to behave like the built-in - // AliasPlugin that implements resolve.fallback -- it comes after - // NextPlugin. - // - // The number just needs to be greater than zero to come after the - // defaults (tapable assigned them stage 0 by default). - { name: 'my-resolver-plugin', stage: 10 }, - async (request, context, callback) => { - try { - if (!isRelevantRequest(request) || request.request.startsWith('@embroider/externals/')) { - callback(); - return; - } - let result = this.resolver.fallbackResolve(request.request, request.context.issuer); - this.#resolve(result, resolver, request, context, callback); - } catch (err) { + 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); } + } else { + callback(null, result); } - ); + }); + } + + apply(compiler: Compiler) { + this.#addLoaderAlias(compiler, virtualLoaderName, resolve(__dirname, './virtual-loader')); + + 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) + ); + }); } } interface Request { request: string; - path: string; - context: { + context: string; + contextInfo: { issuer: string; }; } +type CB = (err: Error | null, result?: Module) => void; + function isRelevantRequest(request: any): request is Request { return ( typeof request.request === 'string' && - typeof request.context?.issuer === 'string' && - request.context.issuer !== '' && - typeof request.path === '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 ); } - -function wrapCallback(callback: (err?: Error | null, result?: T) => void) { - return (err: Error | null, result: T) => { - if (err) return callback(err); - if (result) return callback(null, result); - return callback(); - }; -} diff --git a/tests/scenarios/v2-addon-dev-test.ts b/tests/scenarios/v2-addon-dev-test.ts index 1d28b79fc..a68430613 100644 --- a/tests/scenarios/v2-addon-dev-test.ts +++ b/tests/scenarios/v2-addon-dev-test.ts @@ -16,7 +16,7 @@ appScenarios addon.pkg.name = 'v2-addon'; addon.pkg.files = ['dist']; addon.pkg.exports = { - './*': './dist/*', + './*': './dist/*.js', './addon-main.js': './addon-main.js', './package.json': './package.json', }; diff --git a/tests/scenarios/v2-addon-dev-typescript-test.ts b/tests/scenarios/v2-addon-dev-typescript-test.ts index a070824a5..4766b7ed9 100644 --- a/tests/scenarios/v2-addon-dev-typescript-test.ts +++ b/tests/scenarios/v2-addon-dev-typescript-test.ts @@ -13,7 +13,7 @@ appScenarios addon.pkg.name = 'v2-addon'; addon.pkg.files = ['dist']; addon.pkg.exports = { - './*': './dist/*', + './*': './dist/*.js', './addon-main.js': './addon-main.js', './package.json': './package.json', };