pulumi/sdk/nodejs/runtime/closure/package.ts

278 lines
9.4 KiB
TypeScript

// Copyright 2016-2022, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as upath from "upath";
type Exports = string | { [key: string]: SubExports };
type SubExports = string | { [key: string]: SubExports } | null;
type PackageDefinition = {
name: string;
exports?: Exports;
};
// TODO[issue] handle https://nodejs.org/api/packages.html#package-entry-points
//
// Warning: Introducing the "exports" field prevents consumers of a package from using
// any entry points that are not defined, including the package.json
// (e.g. require("your-package/package.json"). This will likely be a breaking change.
function getPackageDefinition(path: string): PackageDefinition | undefined {
try {
const directories = path.split(upath.sep);
let last: string | undefined = undefined;
let lastFullPath: string | undefined = undefined;
while (directories.length > 0) {
const curPath = directories.join(upath.sep);
try {
lastFullPath = require.resolve(curPath);
last = curPath;
} catch (e) {
// current path is not a module
}
directories.pop();
}
if (last === undefined || lastFullPath === undefined) {
throw new Error(`no package.json found for ${path}`);
}
const packageDefinitionAbsPath = lastFullPath.slice(0, lastFullPath.indexOf(last)) + last + "/package.json";
return require(packageDefinitionAbsPath);
} catch (err) {
return undefined;
}
}
// a module's implementations are leaves of the document tree.
function getAllLeafStrings(objectOrPath: SubExports, opts?: RequireOpts): string[] {
if (objectOrPath === null) {
// module blacklisted return no implementations
return [];
}
if (typeof objectOrPath === "string") {
return [objectOrPath];
}
const strings: string[] = [];
for (const [key, value] of Object.entries(objectOrPath)) {
if (opts && !opts.isRequire && key === "require") {
continue;
}
if (opts && !opts.isImport && key === "import") {
continue;
}
const leaves = getAllLeafStrings(value);
if (leaves.length === 0) {
// if there's an environment where this export does not work,
// don't suggest requires from this match as a more preferable path may
// match this file.
return [];
}
strings.push(...leaves);
}
return strings;
}
// from https://github.com/nodejs/node/blob/b191e66ddf/lib/internal/modules/esm/resolve.js#L686
function patternKeyCompare(a: string, b: string) {
const aPatternIndex = a.indexOf("*");
const bPatternIndex = b.indexOf("*");
const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1;
const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1;
if (baseLenA > baseLenB) {
return -1;
}
if (baseLenB > baseLenA) {
return 1;
}
if (aPatternIndex === -1) {
return 1;
}
if (bPatternIndex === -1) {
return -1;
}
if (a.length > b.length) {
return -1;
}
if (b.length > a.length) {
return 1;
}
return 0;
}
type SrcPrefix = string;
type Rule = [
SrcPrefix,
{
modPrefix: string;
modSuffix: string;
srcSuffix: string;
},
];
function makeRule(srcPattern: string, modPattern: string): Rule {
const srcSplit = srcPattern.split("*"); // NodeJS doesn't error out when provided multiple '*'.
const modSplit = modPattern.split("*");
if (srcSplit.length > 2 || modSplit.length > 2) {
// there is undefined behavior on more than 1 "*"
// see https://github.com/nodejs/node/blob/b191e66ddf/lib/internal/modules/esm/resolve.js#L664
throw new Error("multiple wildcards in single export target specification");
}
const [srcPrefix, srcSuffix] = srcSplit;
const [modPrefix, modSuffix] = modSplit;
return [
srcPrefix,
{
modPrefix,
modSuffix: modSuffix || "",
srcSuffix: srcSuffix || "",
},
];
}
class WildcardMap {
private map: { [srcPrefix: string]: string };
private rules: Rule[];
constructor(matches: [string, string[]][]) {
this.map = {};
const rules: Rule[] = [];
for (const [match, srcPaths] of matches) {
for (const srcPath of srcPaths) {
if (srcPath.includes("*")) {
// wildcard match
rules.push(makeRule(srcPath, match));
continue;
}
this.map[srcPath] = match;
}
}
this.rules = rules.sort((a, b) => patternKeyCompare(a[0], b[0]));
}
get(srcName: string): string | undefined {
if (this.map[srcName]) {
return this.map[srcName];
}
for (const [srcPrefix, srcRule] of this.rules) {
if (!srcName.startsWith(srcPrefix) || !srcName.endsWith(srcRule.srcSuffix)) {
continue;
}
const srcSubpath = srcName.slice(srcPrefix.length, srcName.length - srcRule.srcSuffix.length);
const result = srcRule.modPrefix + srcSubpath + srcRule.modSuffix;
return result;
}
return undefined;
}
}
function isConditionalSugar(exports: Exports, name: string) {
// exports sugar does not handle mixing ["./path/to/module"] path keys
// and ["default"|"require"|"import"] conditional keys
// details https://github.com/nodejs/node/blob/b191e66ddf/lib/internal/modules/esm/resolve.js#L593
let isSugar = false;
for (const key of Object.keys(exports)) {
if (isSugar && key.startsWith(".")) {
throw new Error(
`${name}:package.json "exports" cannot contain some keys starting with "." and some not.` +
" The exports object must either be an object of package subpath keys" +
" or an object of main entry condition name keys only.",
);
}
if (!key.startsWith(".")) {
isSugar = true;
continue;
}
}
return isSugar;
}
class ModuleMap {
readonly name: string;
private wildcardMap: WildcardMap;
constructor(name: string, exports: Exports, opts?: RequireOpts) {
this.name = name;
if (isConditionalSugar(exports, name)) {
// the exports keys are not paths meaning it is an exports sugar we need to simplify
exports = { ".": exports };
}
const rules: [string, string[]][] = [];
for (const [modPath, objectOrPath] of Object.entries(exports)) {
const modName: string = name + modPath.slice(1);
const leaves = getAllLeafStrings(objectOrPath, opts);
rules.push([modName, leaves.map((leaf) => name + leaf.slice(1))]);
}
this.wildcardMap = new WildcardMap(rules);
}
get(srcName: string) {
const modPath = this.wildcardMap.get(srcName);
if (modPath === undefined) {
throw new Error(`package.json export path for "${srcName}" not found`);
}
return modPath;
}
}
type RequireOpts = {
isRequire?: boolean;
isImport?: boolean;
};
/*
We need to resolve from a source file path to a valid module export.
Exports to source file is a many-to-one relationship. Reversing this is a one-to-many relationship.
Any of the initial exports are aliases to the same module and
we assume to be semantically equivalent. This makes it a one-to-any relationship.
for example,
<./package.json>
"exports": {
"./foo.js": "./lib/index.js",
"./bar.js": "./lib/index.js",
}
we will resolve ./lib/index.js into either ./foo.js or ./bar.js
a module can resolve into many files conditionally, but aliases are treated as equivalent.
Due to null specifiers for modules and this one-to-many relationship, we assume that anything with a
null specifier may be unreachable on a different platform and opt for a different alias to cover it if
it exists.
Exports ending in "/" will be deprecated by node.
For more details https://nodejs.org/api/esm.html#resolution-algorithm
*/
export function getModuleFromPath(
path: string,
packageDefinition?: PackageDefinition,
opts: RequireOpts = { isRequire: true },
) {
packageDefinition = packageDefinition || getPackageDefinition(path);
if (packageDefinition === undefined) {
return path;
}
if (packageDefinition.exports === undefined) {
return path;
}
if (typeof packageDefinition.exports === "string") {
return packageDefinition.name;
}
if (typeof packageDefinition.exports === "object") {
const modMap = new ModuleMap(packageDefinition.name, packageDefinition.exports, opts);
const modulePath = modMap.get(path);
return modulePath;
}
return path;
}