nx/packages/vite/src/plugins/plugin.ts

318 lines
7.8 KiB
TypeScript

import {
CreateDependencies,
CreateNodes,
CreateNodesContext,
detectPackageManager,
joinPathFragments,
readJsonFile,
TargetConfiguration,
writeJsonFile,
} from '@nx/devkit';
import { dirname, isAbsolute, join, relative } from 'path';
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
import { existsSync, readdirSync } from 'fs';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory';
import { getLockFileName } from '@nx/js';
import { loadViteDynamicImport } from '../utils/executor-utils';
export interface VitePluginOptions {
buildTargetName?: string;
testTargetName?: string;
serveTargetName?: string;
previewTargetName?: string;
serveStaticTargetName?: string;
}
const cachePath = join(projectGraphCacheDirectory, 'vite.hash');
const targetsCache = readTargetsCache();
function readTargetsCache(): Record<
string,
Record<string, TargetConfiguration>
> {
return existsSync(cachePath) ? readJsonFile(cachePath) : {};
}
function writeTargetsToCache() {
const oldCache = readTargetsCache();
writeJsonFile(cachePath, {
...oldCache,
...targetsCache,
});
}
export const createDependencies: CreateDependencies = () => {
writeTargetsToCache();
return [];
};
export const createNodes: CreateNodes<VitePluginOptions> = [
'**/{vite,vitest}.config.{js,ts,mjs,mts,cjs,cts}',
async (configFilePath, options, context) => {
const projectRoot = dirname(configFilePath);
// Do not create a project if package.json and project.json isn't there.
const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot));
if (
!siblingFiles.includes('package.json') &&
!siblingFiles.includes('project.json')
) {
return {};
}
options = normalizeOptions(options);
// We do not want to alter how the hash is calculated, so appending the config file path to the hash
// to prevent vite/vitest files overwriting the target cache created by the other
const hash =
calculateHashForCreateNodes(projectRoot, options, context, [
getLockFileName(detectPackageManager(context.workspaceRoot)),
]) + configFilePath;
targetsCache[hash] ??= await buildViteTargets(
configFilePath,
projectRoot,
options,
context
);
return {
projects: {
[projectRoot]: {
root: projectRoot,
targets: targetsCache[hash],
},
},
};
},
];
async function buildViteTargets(
configFilePath: string,
projectRoot: string,
options: VitePluginOptions,
context: CreateNodesContext
) {
const absoluteConfigFilePath = joinPathFragments(
context.workspaceRoot,
configFilePath
);
// Workaround for the `build$3 is not a function` error that we sometimes see in agents.
// This should be removed later once we address the issue properly
try {
const importEsbuild = () => new Function('return import("esbuild")')();
await importEsbuild();
} catch {
// do nothing
}
const { resolveConfig } = await loadViteDynamicImport();
const viteConfig = await resolveConfig(
{
configFile: absoluteConfigFilePath,
mode: 'development',
},
'build'
);
const { buildOutputs, testOutputs, hasTest, isBuildable } = getOutputs(
viteConfig,
projectRoot,
context.workspaceRoot
);
const namedInputs = getNamedInputs(projectRoot, context);
const targets: Record<string, TargetConfiguration> = {};
// If file is not vitest.config and buildable, create targets for build, serve, preview and serve-static
const hasRemixPlugin =
viteConfig.plugins && viteConfig.plugins.some((p) => p.name === 'remix');
if (
!configFilePath.includes('vitest.config') &&
!hasRemixPlugin &&
isBuildable
) {
targets[options.buildTargetName] = await buildTarget(
options.buildTargetName,
namedInputs,
buildOutputs,
projectRoot
);
targets[options.serveTargetName] = serveTarget(projectRoot);
targets[options.previewTargetName] = previewTarget(projectRoot);
targets[options.serveStaticTargetName] = serveStaticTarget(options) as {};
}
// if file is vitest.config or vite.config has definition for test, create target for test
if (configFilePath.includes('vitest.config') || hasTest) {
targets[options.testTargetName] = await testTarget(
namedInputs,
testOutputs,
projectRoot
);
}
return targets;
}
async function buildTarget(
buildTargetName: string,
namedInputs: {
[inputName: string]: any[];
},
outputs: string[],
projectRoot: string
) {
return {
command: `vite build`,
options: { cwd: joinPathFragments(projectRoot) },
cache: true,
dependsOn: [`^${buildTargetName}`],
inputs: [
...('production' in namedInputs
? ['production', '^production']
: ['default', '^default']),
{
externalDependencies: ['vite'],
},
],
outputs,
};
}
function serveTarget(projectRoot: string) {
const targetConfig: TargetConfiguration = {
command: `vite serve`,
options: {
cwd: joinPathFragments(projectRoot),
},
};
return targetConfig;
}
function previewTarget(projectRoot: string) {
const targetConfig: TargetConfiguration = {
command: `vite preview`,
options: {
cwd: joinPathFragments(projectRoot),
},
};
return targetConfig;
}
async function testTarget(
namedInputs: {
[inputName: string]: any[];
},
outputs: string[],
projectRoot: string
) {
return {
command: `vitest`,
options: { cwd: joinPathFragments(projectRoot) },
cache: true,
inputs: [
...('production' in namedInputs
? ['default', '^production']
: ['default', '^default']),
{
externalDependencies: ['vitest'],
},
{ env: 'CI' },
],
outputs,
};
}
function serveStaticTarget(options: VitePluginOptions) {
const targetConfig: TargetConfiguration = {
executor: '@nx/web:file-server',
options: {
buildTarget: `${options.buildTargetName}`,
spa: true,
},
};
return targetConfig;
}
function getOutputs(
viteConfig: Record<string, any> | undefined,
projectRoot: string,
workspaceRoot: string
): {
buildOutputs: string[];
testOutputs: string[];
hasTest: boolean;
isBuildable: boolean;
} {
const { build, test } = viteConfig;
const buildOutputPath = normalizeOutputPath(
build?.outDir,
projectRoot,
workspaceRoot,
'dist'
);
const isBuildable =
build?.lib ||
build?.rollupOptions?.inputs ||
existsSync(join(workspaceRoot, projectRoot, 'index.html'));
const reportsDirectoryPath = normalizeOutputPath(
test?.coverage?.reportsDirectory,
projectRoot,
workspaceRoot,
'coverage'
);
return {
buildOutputs: [buildOutputPath],
testOutputs: [reportsDirectoryPath],
hasTest: !!test,
isBuildable,
};
}
function normalizeOutputPath(
outputPath: string | undefined,
projectRoot: string,
workspaceRoot: string,
path: 'coverage' | 'dist'
): string | undefined {
if (!outputPath) {
if (projectRoot === '.') {
return `{projectRoot}/${path}`;
} else {
return `{workspaceRoot}/${path}/{projectRoot}`;
}
} else {
if (isAbsolute(outputPath)) {
return `{workspaceRoot}/${relative(workspaceRoot, outputPath)}`;
} else {
if (outputPath.startsWith('..')) {
return join('{workspaceRoot}', join(projectRoot, outputPath));
} else {
return join('{projectRoot}', outputPath);
}
}
}
}
function normalizeOptions(options: VitePluginOptions): VitePluginOptions {
options ??= {};
options.buildTargetName ??= 'build';
options.serveTargetName ??= 'serve';
options.previewTargetName ??= 'preview';
options.testTargetName ??= 'test';
options.serveStaticTargetName ??= 'serve-static';
return options;
}