@jsenv/core
Version:
Tool to develop, test and build js projects
467 lines (447 loc) • 14.8 kB
JavaScript
/*
* - should I restore eventual search params lost during node esm resolution
* - what about symlinks?
* It feels like I should apply symlink (when we don't want to preserve them)
* once a file:/// url is found, regardless
* if that comes from node resolution or anything else (not even magic resolution)
* it should likely be an other plugin happening after the others
*/
import {
applyNodeEsmResolution,
defaultLookupPackageScope,
defaultReadPackageJson,
readCustomConditionsFromProcessArgs,
} from "@jsenv/node-esm-resolution";
import { URL_META } from "@jsenv/url-meta";
import { urlToBasename, urlToExtension } from "@jsenv/urls";
import { readFileSync } from "node:fs";
export const createNodeEsmResolver = ({
runtimeCompat,
rootDirectoryUrl,
packageConditions = {},
packageConditionsConfig,
preservesSymlink,
}) => {
const buildPackageConditions = createBuildPackageConditions(
packageConditions,
{
packageConditionsConfig,
rootDirectoryUrl,
runtimeCompat,
},
);
return (reference) => {
if (reference.type === "package_json") {
return reference.specifier;
}
const { ownerUrlInfo } = reference;
if (reference.specifierPathname[0] === "/") {
return null; // let it to jsenv_web_resolution
}
let parentUrl;
if (reference.baseUrl) {
parentUrl = reference.baseUrl;
} else if (ownerUrlInfo.originalUrl?.startsWith("http")) {
parentUrl = ownerUrlInfo.originalUrl;
} else {
parentUrl = ownerUrlInfo.url;
}
if (!parentUrl.startsWith("file:")) {
return null; // let it to jsenv_web_resolution
}
const { specifier } = reference;
// specifiers like "#something" have a special meaning for Node.js
// but can also be used in .css and .html files for example and should not be modified
// by node esm resolution
const webResolutionFallback =
ownerUrlInfo.type !== "js_module" ||
reference.type === "sourcemap_comment";
const resolveNodeEsmFallbackOnWeb = createResolverWithFallbackOnError(
applyNodeEsmResolution,
({ specifier, parentUrl }) => {
const url = new URL(specifier, parentUrl).href;
return { url };
},
);
const DELEGATE_TO_WEB_RESOLUTION_PLUGIN = {};
const resolveNodeEsmFallbackNullToDelegateToWebPlugin =
createResolverWithFallbackOnError(
applyNodeEsmResolution,
() => DELEGATE_TO_WEB_RESOLUTION_PLUGIN,
);
const conditions = buildPackageConditions(specifier, parentUrl, {
webResolutionFallback,
resolver: webResolutionFallback
? resolveNodeEsmFallbackOnWeb
: applyNodeEsmResolution,
});
const resolver = webResolutionFallback
? resolveNodeEsmFallbackNullToDelegateToWebPlugin
: applyNodeEsmResolution;
const result = resolver({
conditions,
parentUrl,
specifier,
preservesSymlink,
});
if (result === DELEGATE_TO_WEB_RESOLUTION_PLUGIN) {
return null;
}
const { url, type, isMain, packageDirectoryUrl } = result;
// try to give a more meaningful filename after build
if (isMain && packageDirectoryUrl) {
const basename = urlToBasename(url);
if (basename === "main" || basename === "index") {
const parentBasename = urlToBasename(new URL("../../", url));
const dirname = urlToBasename(packageDirectoryUrl);
let filenameHint = "";
if (parentBasename[0] === "@") {
filenameHint += `${parentBasename}_`;
}
const extension = urlToExtension(url);
filenameHint += `${dirname}_${basename}${extension}`;
reference.filenameHint = filenameHint;
}
}
if (ownerUrlInfo.context.build) {
return url;
}
const dependsOnPackageJson =
type !== "relative_specifier" &&
type !== "absolute_specifier" &&
type !== "node_builtin_specifier";
if (dependsOnPackageJson) {
// this reference depends on package.json and node_modules
// to be resolved. Each file using this specifier
// must be invalidated when corresponding package.json changes
addRelationshipWithPackageJson({
reference,
packageJsonUrl: `${packageDirectoryUrl}package.json`,
field: type.startsWith("field:")
? `#${type.slice("field:".length)}`
: "",
});
}
// without this check a file inside a project without package.json
// could be considered as a node module if there is a ancestor package.json
// but we want to version only node modules
if (url.includes("/node_modules/")) {
const packageDirectoryUrl = defaultLookupPackageScope(url);
if (
packageDirectoryUrl &&
packageDirectoryUrl !== ownerUrlInfo.context.rootDirectoryUrl
) {
const packageVersion =
defaultReadPackageJson(packageDirectoryUrl).version;
// package version can be null, see https://github.com/babel/babel/blob/2ce56e832c2dd7a7ed92c89028ba929f874c2f5c/packages/babel-runtime/helpers/esm/package.json#L2
if (packageVersion) {
addRelationshipWithPackageJson({
reference,
packageJsonUrl: `${packageDirectoryUrl}package.json`,
field: "version",
hasVersioningEffect: true,
});
}
reference.version = packageVersion;
}
}
return url;
};
};
const createBuildPackageConditions = (
packageConditions,
{ packageConditionsConfig, rootDirectoryUrl, runtimeCompat },
) => {
let resolveConditionsFromSpecifier = () => null;
let resolveConditionsFromContext = () => [];
from_specifier: {
if (!packageConditionsConfig) {
break from_specifier;
}
const keys = Object.keys(packageConditionsConfig);
if (keys.length === 0) {
break from_specifier;
}
const associationsRaw = {};
for (const key of keys) {
const associatedValue = packageConditionsConfig[key];
if (!isBareSpecifier(key)) {
const url = new URL(key, rootDirectoryUrl);
associationsRaw[url] = associatedValue;
continue;
}
try {
if (key.endsWith("/")) {
const { packageDirectoryUrl } = applyNodeEsmResolution({
specifier: key.slice(0, -1), // avoid package path not exported
parentUrl: rootDirectoryUrl,
});
const url = packageDirectoryUrl;
associationsRaw[url] = associatedValue;
continue;
}
const { url } = applyNodeEsmResolution({
specifier: key,
parentUrl: rootDirectoryUrl,
});
associationsRaw[url] = associatedValue;
} catch {
const url = new URL(key, rootDirectoryUrl);
associationsRaw[url] = associatedValue;
}
}
const associations = URL_META.resolveAssociations(
{
conditions: associationsRaw,
},
rootDirectoryUrl,
);
resolveConditionsFromSpecifier = (specifier, importer, { resolver }) => {
let associatedValue;
if (isBareSpecifier(specifier)) {
const { url } = resolver({
specifier,
parentUrl: importer,
});
associatedValue = URL_META.applyAssociations({ url, associations });
} else {
associatedValue = URL_META.applyAssociations({
url: importer,
associations,
});
}
if (!associatedValue) {
return undefined;
}
if (associatedValue.conditions) {
return associatedValue.conditions;
}
return undefined;
};
}
from_context: {
const nodeRuntimeEnabled = Object.keys(runtimeCompat).includes("node");
// https://nodejs.org/api/esm.html#resolver-algorithm-specification
const devResolver = (specifier, importer, { resolver }) => {
if (isBareSpecifier(specifier)) {
const { url } = resolver({
specifier,
parentUrl: importer,
});
return !url.includes("/node_modules/");
}
return !importer.includes("/node_modules/");
};
const conditionDefaultResolvers = {
"dev:*": devResolver,
"development": devResolver,
"node": nodeRuntimeEnabled,
"browser": !nodeRuntimeEnabled,
"import": true,
};
const conditionResolvers = {
...conditionDefaultResolvers,
};
let wildcardToRemoveSet = new Set();
const addCustomResolver = (condition, customResolver) => {
for (const conditionCandidate of Object.keys(conditionDefaultResolvers)) {
if (conditionCandidate.includes("*")) {
const conditionRegex = new RegExp(
`^${conditionCandidate.replace(/\*/g, "(.*)")}$`,
);
if (conditionRegex.test(condition)) {
const existingResolver =
conditionDefaultResolvers[conditionCandidate];
wildcardToRemoveSet.add(conditionCandidate);
conditionResolvers[condition] = combineTwoPackageConditionResolvers(
existingResolver,
customResolver,
);
return;
}
}
}
const existingResolver = conditionDefaultResolvers[condition];
if (existingResolver) {
conditionResolvers[condition] = combineTwoPackageConditionResolvers(
existingResolver,
customResolver,
);
return;
}
conditionResolvers[condition] = customResolver;
};
custom_resolvers_from_process_args: {
const processArgConditions = readCustomConditionsFromProcessArgs();
for (const processArgCondition of processArgConditions) {
addCustomResolver(processArgCondition, true);
}
}
custom_resolvers_from_package_conditions: {
for (const key of Object.keys(packageConditions)) {
const value = packageConditions[key];
let customResolver;
if (typeof value === "object") {
const associations = URL_META.resolveAssociations(
{ applies: value },
(pattern) => {
if (isBareSpecifier(pattern)) {
try {
if (pattern.endsWith("/")) {
// avoid package path not exported
const { packageDirectoryUrl } = applyNodeEsmResolution({
specifier: pattern.slice(0, -1),
parentUrl: rootDirectoryUrl,
});
return packageDirectoryUrl;
}
const { url } = applyNodeEsmResolution({
specifier: pattern,
parentUrl: rootDirectoryUrl,
});
return url;
} catch {
return new URL(pattern, rootDirectoryUrl);
}
}
return new URL(pattern, rootDirectoryUrl);
},
);
customResolver = (specifier, importer, { resolver }) => {
if (isBareSpecifier(specifier)) {
const { url } = resolver({
specifier,
parentUrl: importer,
});
const { applies } = URL_META.applyAssociations({
url,
associations,
});
return applies;
}
const { applies } = URL_META.applyAssociations({
url: importer,
associations,
});
return applies;
};
} else if (typeof value === "function") {
customResolver = value;
} else {
customResolver = value;
}
addCustomResolver(key, customResolver);
}
}
for (const wildcardToRemove of wildcardToRemoveSet) {
delete conditionResolvers[wildcardToRemove];
}
const conditionCandidateArray = Object.keys(conditionResolvers);
resolveConditionsFromContext = (specifier, importer, params) => {
const conditions = [];
for (const conditionCandidate of conditionCandidateArray) {
const conditionResolver = conditionResolvers[conditionCandidate];
if (typeof conditionResolver === "function") {
if (conditionResolver(specifier, importer, params)) {
conditions.push(conditionCandidate);
}
} else if (conditionResolver) {
conditions.push(conditionCandidate);
}
}
return conditions;
};
}
return (specifier, importer, params) => {
const conditionsForThisSpecifier = resolveConditionsFromSpecifier(
specifier,
importer,
params,
);
if (conditionsForThisSpecifier) {
return conditionsForThisSpecifier;
}
const conditionsFromContext = resolveConditionsFromContext(
specifier,
importer,
params,
);
if (conditionsFromContext) {
return conditionsFromContext;
}
return [];
};
};
const combineTwoPackageConditionResolvers = (first, second) => {
if (typeof second !== "function") {
return second;
}
return (...args) => {
const secondResult = second(...args);
if (secondResult !== undefined) {
return secondResult;
}
if (typeof first === "function") {
return first(...args);
}
return first;
};
};
const addRelationshipWithPackageJson = ({
reference,
packageJsonUrl,
field,
hasVersioningEffect = false,
}) => {
const { ownerUrlInfo } = reference;
for (const referenceToOther of ownerUrlInfo.referenceToOthersSet) {
if (
referenceToOther.type === "package_json" &&
referenceToOther.subtype === field
) {
return;
}
}
const packageJsonReference = reference.addImplicit({
type: "package_json",
subtype: field,
specifier: packageJsonUrl,
hasVersioningEffect,
isWeak: true,
});
// we don't cook package.json files, we just maintain their content
// to be able to check if it has changed later on
if (packageJsonReference.urlInfo.content === undefined) {
const packageJsonContentAsBuffer = readFileSync(new URL(packageJsonUrl));
packageJsonReference.urlInfo.type = "json";
packageJsonReference.urlInfo.kitchen.urlInfoTransformer.setContent(
packageJsonReference.urlInfo,
String(packageJsonContentAsBuffer),
);
}
};
const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
return (params) => {
try {
return mainResolver(params);
} catch {
return fallbackResolver(params);
}
};
};
const isBareSpecifier = (specifier) => {
if (
specifier[0] === "/" ||
specifier.startsWith("./") ||
specifier.startsWith("../")
) {
return false;
}
try {
// eslint-disable-next-line no-new
new URL(specifier);
return false;
} catch {
return true;
}
};