From 8c1451c4785d73bb3cd37eaa889e2c1845594d25 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sun, 8 Sep 2024 19:46:46 -0400 Subject: [PATCH] moving extension search to its own esbuild hook Because it needs to be available to both the embroider resolver and the template-only-component resolver. --- packages/vite/src/esbuild-request.ts | 153 ++++++++++---------------- packages/vite/src/esbuild-resolver.ts | 73 +++++++++++- 2 files changed, 129 insertions(+), 97 deletions(-) diff --git a/packages/vite/src/esbuild-request.ts b/packages/vite/src/esbuild-request.ts index 68aa67dfc..59d63a05a 100644 --- a/packages/vite/src/esbuild-request.ts +++ b/packages/vite/src/esbuild-request.ts @@ -1,21 +1,17 @@ import { type ModuleRequest, cleanUrl, packageName } from '@embroider/core'; -import type { ImportKind, OnResolveResult, PluginBuild, ResolveResult } from 'esbuild'; -import { dirname, extname } from 'path'; +import type { ImportKind, OnResolveResult, PluginBuild } from 'esbuild'; +import { dirname } from 'path'; import type { PackageCache as _PackageCache, Resolution } from '@embroider/core'; import { externalName } from '@embroider/reverse-exports'; -// TODO: make this share with vite config. We may need to pass it directly as an -// argument to our esbuild plugin, or perhaps share it via embroider's -// resolver-config.json -const extensions = ['.mjs', '.gjs', '.js', '.mts', '.gts', '.ts', '.hbs', '.json']; - type PublicAPI = { [K in keyof T]: T[K] }; type PackageCache = PublicAPI<_PackageCache>; export class EsBuildModuleRequest implements ModuleRequest { static from( packageCache: PackageCache, + phase: 'bundling' | 'scanning', context: PluginBuild, kind: ImportKind, source: string, @@ -30,6 +26,7 @@ export class EsBuildModuleRequest implements ModuleRequest { let fromFile = cleanUrl(importer); return new EsBuildModuleRequest( packageCache, + phase, context, kind, source, @@ -44,6 +41,7 @@ export class EsBuildModuleRequest implements ModuleRequest { private constructor( private packageCache: PackageCache, + private phase: 'bundling' | 'scanning', private context: PluginBuild, private kind: ImportKind, readonly specifier: string, @@ -52,16 +50,7 @@ export class EsBuildModuleRequest implements ModuleRequest { readonly isVirtual: boolean, readonly isNotFound: boolean, readonly resolvedTo: Resolution | undefined - ) { - let plugins = (this.context.initialOptions.plugins ?? []).map(p => p.name); - if (plugins.includes('vite:dep-pre-bundle')) { - this.phase = 'bundling'; - } else if (plugins.includes('vite:dep-scan')) { - this.phase = 'scanning'; - } else { - throw new Error(`cannot identify what phase vite is in. Saw plugins: ${plugins.join(', ')}`); - } - } + ) {} get debugType() { return 'esbuild'; @@ -70,6 +59,7 @@ export class EsBuildModuleRequest implements ModuleRequest { alias(newSpecifier: string) { return new EsBuildModuleRequest( this.packageCache, + this.phase, this.context, this.kind, newSpecifier, @@ -86,6 +76,7 @@ export class EsBuildModuleRequest implements ModuleRequest { } else { return new EsBuildModuleRequest( this.packageCache, + this.phase, this.context, this.kind, this.specifier, @@ -100,6 +91,7 @@ export class EsBuildModuleRequest implements ModuleRequest { virtualize(filename: string) { return new EsBuildModuleRequest( this.packageCache, + this.phase, this.context, this.kind, filename, @@ -113,6 +105,7 @@ export class EsBuildModuleRequest implements ModuleRequest { withMeta(meta: Record | undefined): this { return new EsBuildModuleRequest( this.packageCache, + this.phase, this.context, this.kind, this.specifier, @@ -126,6 +119,7 @@ export class EsBuildModuleRequest implements ModuleRequest { notFound(): this { return new EsBuildModuleRequest( this.packageCache, + this.phase, this.context, this.kind, this.specifier, @@ -140,6 +134,7 @@ export class EsBuildModuleRequest implements ModuleRequest { resolveTo(resolution: Resolution): this { return new EsBuildModuleRequest( this.packageCache, + this.phase, this.context, this.kind, this.specifier, @@ -151,24 +146,6 @@ export class EsBuildModuleRequest implements ModuleRequest { ) as this; } - private phase: 'bundling' | 'scanning'; - - private *extensionSearch(specifier: string): Generator { - yield specifier; - // when there's no explicit extension, we may do extension search - if (extname(specifier) === '') { - // during the bundling phase, esbuild is not configured with any extension - // search of its own, so we need to do it. (During the scanning phase, all - // of vite's resolver is plugged into esbuild, which *does* already handle - // the resolvable extensions.) - if (this.phase === 'bundling') { - for (let ext of extensions) { - yield specifier + ext; - } - } - } - } - async defaultResolve(): Promise> { const request = this; if (request.isVirtual) { @@ -189,73 +166,61 @@ export class EsBuildModuleRequest implements ModuleRequest { }; } - let firstResult: ResolveResult | undefined; - - for (let requestName of this.extensionSearch(request.specifier)) { - requestStatus(requestName); + requestStatus(request.specifier); - let result = await this.context.resolve(requestName, { - importer: request.fromFile, - resolveDir: dirname(request.fromFile), - kind: this.kind, - pluginData: { - embroider: { - enableCustomResolver: false, - meta: request.meta, - }, + let result = await this.context.resolve(request.specifier, { + importer: request.fromFile, + resolveDir: dirname(request.fromFile), + kind: this.kind, + pluginData: { + embroider: { + enableCustomResolver: false, + meta: request.meta, }, - }); + }, + }); - let status = readStatus(requestName); + let status = readStatus(request.specifier); - if (result.errors.length > 0 || status === 'not_found') { - if (!firstResult) { - // if extension search fails, we want to let the first failure be the - // one that propagates, so that the error message makes sense. - firstResult = result; - } - // let extension search continue - } else if (result.external) { - return { type: 'ignored', result }; - } else { - if (this.phase === 'bundling') { - // we need to ensure that we don't traverse back into the app while - // doing dependency pre-bundling. There are multiple ways an addon can - // resolve things from the app, due to the existince of both app-js - // (modules in addons that are logically part of the app's namespace) - // and non-strict handlebars (which resolves - // components/helpers/modifiers against the app's global pool). - let pkg = this.packageCache.ownerOfFile(result.path); - if (pkg?.root === this.packageCache.appRoot) { - let externalizedName = requestName; - if (!packageName(externalizedName)) { - // the request was a relative path. This won't remain valid once - // it has been bundled into vite/deps. But we know it targets the - // app, so we can always convert it into a non-relative import - // from the app's namespace - // - // IMPORTANT: whenever an addon resolves a relative path to the - // app, it does so because our code in the core resolver has - // rewritten the request to be relative to the app's root. So here - // we will only ever encounter relative paths that are already - // relative to the app's root directory. - externalizedName = externalName(pkg.packageJSON, externalizedName) || externalizedName; - } - return { - type: 'ignored', - result: { - path: externalizedName, - external: true, - }, - }; + if (result.errors.length > 0 || status === 'not_found') { + return { type: 'not_found', err: result }; + } else if (result.external) { + return { type: 'ignored', result }; + } else { + if (this.phase === 'bundling') { + // we need to ensure that we don't traverse back into the app while + // doing dependency pre-bundling. There are multiple ways an addon can + // resolve things from the app, due to the existince of both app-js + // (modules in addons that are logically part of the app's namespace) + // and non-strict handlebars (which resolves + // components/helpers/modifiers against the app's global pool). + let pkg = this.packageCache.ownerOfFile(result.path); + if (pkg?.root === this.packageCache.appRoot) { + let externalizedName = request.specifier; + if (!packageName(externalizedName)) { + // the request was a relative path. This won't remain valid once + // it has been bundled into vite/deps. But we know it targets the + // app, so we can always convert it into a non-relative import + // from the app's namespace + // + // IMPORTANT: whenever an addon resolves a relative path to the + // app, it does so because our code in the core resolver has + // rewritten the request to be relative to the app's root. So here + // we will only ever encounter relative paths that are already + // relative to the app's root directory. + externalizedName = externalName(pkg.packageJSON, externalizedName) || externalizedName; } + return { + type: 'ignored', + result: { + path: externalizedName, + external: true, + }, + }; } - return { type: 'found', filename: result.path, result, isVirtual: this.isVirtual }; } + return { type: 'found', filename: result.path, result, isVirtual: this.isVirtual }; } - - // cast is safe because we know extensionSearch always yields >0 entries - return { type: 'not_found', err: firstResult! }; } } diff --git a/packages/vite/src/esbuild-resolver.ts b/packages/vite/src/esbuild-resolver.ts index e62f82316..a43e101f2 100644 --- a/packages/vite/src/esbuild-resolver.ts +++ b/packages/vite/src/esbuild-resolver.ts @@ -1,4 +1,4 @@ -import type { Plugin as EsBuildPlugin, OnLoadResult } from 'esbuild'; +import type { Plugin as EsBuildPlugin, OnLoadResult, PluginBuild, ResolveResult } from 'esbuild'; import { transform } from '@babel/core'; import { ResolverLoader, virtualContent, needsSyntheticComponentJS } from '@embroider/core'; import { readFileSync } from 'fs-extra'; @@ -6,6 +6,7 @@ import { EsBuildModuleRequest } from './esbuild-request'; import assertNever from 'assert-never'; import { hbsToJS } from '@embroider/core'; import { Preprocessor } from 'content-tag'; +import { extname } from 'path'; const templateOnlyComponent = `import templateOnly from '@ember/component/template-only';\n` + `export default templateOnly();\n`; @@ -45,10 +46,13 @@ export function esBuildResolver(): EsBuildPlugin { return { name: 'embroider-esbuild-resolver', setup(build) { + const phase = detectPhase(build); + // Embroider Resolver build.onResolve({ filter: /./ }, async ({ path, importer, pluginData, kind }) => { let request = EsBuildModuleRequest.from( resolverLoader.resolver.packageCache, + phase, build, kind, path, @@ -72,7 +76,7 @@ export function esBuildResolver(): EsBuildPlugin { // template-only-component synthesis build.onResolve({ filter: /./ }, async ({ path, importer, namespace, resolveDir, pluginData, kind }) => { - if (pluginData?.embroiderExtensionResolving) { + if (pluginData?.embroiderHBSResolving) { // reentrance return null; } @@ -83,7 +87,7 @@ export function esBuildResolver(): EsBuildPlugin { importer, kind, // avoid reentrance - pluginData: { ...pluginData, embroiderExtensionResolving: true }, + pluginData: { ...pluginData, embroiderHBSResolving: true }, }); if (result.errors.length === 0 && !result.external) { @@ -96,6 +100,43 @@ export function esBuildResolver(): EsBuildPlugin { return result; }); + if (phase === 'bundling') { + // during bundling phase, we need to provide our own extension + // searching. We do it here in its own resolve plugin so that it's + // sitting beneath both embroider resolver and template-only-component + // synthesizer, since both expect the ambient system to have extension + // search. + build.onResolve({ filter: /./ }, async ({ path, importer, namespace, resolveDir, pluginData, kind }) => { + if (pluginData?.embroiderExtensionResolving) { + // reentrance + return null; + } + + let firstResult: ResolveResult | undefined; + + for (let requestName of extensionSearch(path)) { + let result = await build.resolve(requestName, { + namespace, + resolveDir, + importer, + kind, + // avoid reentrance + pluginData: { ...pluginData, embroiderExtensionResolving: true }, + }); + + if (result.errors.length > 0) { + // if extension search fails, we want to let the first failure be the + // one that propagates, so that the error message makes sense. + firstResult = result; + } else { + return result; + } + } + + return firstResult; + }); + } + // we need to handle everything from one of our three special namespaces: build.onLoad({ namespace: 'embroider-template-only-component', filter: /./ }, onLoad); build.onLoad({ namespace: 'embroider-virtual', filter: /./ }, onLoad); @@ -109,3 +150,29 @@ export function esBuildResolver(): EsBuildPlugin { }, }; } + +function detectPhase(build: PluginBuild): 'bundling' | 'scanning' { + let plugins = (build.initialOptions.plugins ?? []).map(p => p.name); + if (plugins.includes('vite:dep-pre-bundle')) { + return 'bundling'; + } else if (plugins.includes('vite:dep-scan')) { + return 'scanning'; + } else { + throw new Error(`cannot identify what phase vite is in. Saw plugins: ${plugins.join(', ')}`); + } +} + +// TODO: make this share with vite config. We may need to pass it directly as an +// argument to our esbuild plugin, or perhaps share it via embroider's +// resolver-config.json +const extensions = ['.mjs', '.gjs', '.js', '.mts', '.gts', '.ts', '.hbs', '.json']; + +function* extensionSearch(specifier: string): Generator { + yield specifier; + // when there's no explicit extension, we may do extension search + if (extname(specifier) === '') { + for (let ext of extensions) { + yield specifier + ext; + } + } +}