Skip to content

Commit

Permalink
moving extension search to its own esbuild hook
Browse files Browse the repository at this point in the history
Because it needs to be available to both the embroider resolver and the template-only-component resolver.
  • Loading branch information
ef4 committed Sep 8, 2024
1 parent bf7655b commit 8c1451c
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 97 deletions.
153 changes: 59 additions & 94 deletions packages/vite/src/esbuild-request.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { [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,
Expand All @@ -30,6 +26,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
let fromFile = cleanUrl(importer);
return new EsBuildModuleRequest(
packageCache,
phase,
context,
kind,
source,
Expand All @@ -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,
Expand All @@ -52,16 +50,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
readonly isVirtual: boolean,
readonly isNotFound: boolean,
readonly resolvedTo: Resolution<OnResolveResult, OnResolveResult> | 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';
Expand All @@ -70,6 +59,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
alias(newSpecifier: string) {
return new EsBuildModuleRequest(
this.packageCache,
this.phase,
this.context,
this.kind,
newSpecifier,
Expand All @@ -86,6 +76,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
} else {
return new EsBuildModuleRequest(
this.packageCache,
this.phase,
this.context,
this.kind,
this.specifier,
Expand All @@ -100,6 +91,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
virtualize(filename: string) {
return new EsBuildModuleRequest(
this.packageCache,
this.phase,
this.context,
this.kind,
filename,
Expand All @@ -113,6 +105,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
withMeta(meta: Record<string, any> | undefined): this {
return new EsBuildModuleRequest(
this.packageCache,
this.phase,
this.context,
this.kind,
this.specifier,
Expand All @@ -126,6 +119,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
notFound(): this {
return new EsBuildModuleRequest(
this.packageCache,
this.phase,
this.context,
this.kind,
this.specifier,
Expand All @@ -140,6 +134,7 @@ export class EsBuildModuleRequest implements ModuleRequest {
resolveTo(resolution: Resolution<OnResolveResult, OnResolveResult>): this {
return new EsBuildModuleRequest(
this.packageCache,
this.phase,
this.context,
this.kind,
this.specifier,
Expand All @@ -151,24 +146,6 @@ export class EsBuildModuleRequest implements ModuleRequest {
) as this;
}

private phase: 'bundling' | 'scanning';

private *extensionSearch(specifier: string): Generator<string> {
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<Resolution<OnResolveResult, OnResolveResult>> {
const request = this;
if (request.isVirtual) {
Expand All @@ -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! };
}
}

Expand Down
73 changes: 70 additions & 3 deletions packages/vite/src/esbuild-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';
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`;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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<string> {
yield specifier;
// when there's no explicit extension, we may do extension search
if (extname(specifier) === '') {
for (let ext of extensions) {
yield specifier + ext;
}
}
}

0 comments on commit 8c1451c

Please sign in to comment.