// Copyright 2016-2018, 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. /* eslint-disable max-len */ import * as fs from "fs"; import * as normalize from "normalize-package-data"; import * as readPackageTree from "read-package-tree"; import * as upath from "upath"; import { log } from "../.."; import * as asset from "../../asset"; import { ResourceError } from "../../errors"; import { Resource } from "../../resource"; /** * Options for controlling what gets returned by [computeCodePaths]. */ export interface CodePathOptions { /** * Local file/directory paths that we always want to include when producing the Assets to be * included for a serialized closure. */ extraIncludePaths?: string[]; /** * Extra packages to include when producing the Assets for a serialized closure. This can be * useful if the packages are acquired in a way that the serialization code does not understand. * For example, if there was some sort of module that was pulled in based off of a computed * string. */ extraIncludePackages?: string[]; /** * Packages to explicitly exclude from the Assets for a serialized closure. This can be used * when clients want to trim down the size of a closure, and they know that some package won't * ever actually be needed at runtime, but is still a dependency of some package that is being * used at runtime. */ extraExcludePackages?: string[]; /** * The resource to log any errors we encounter against. */ logResource?: Resource; } /** * computeCodePaths computes the local node_module paths to include in an uploaded cloud 'Lambda'. * Specifically, it will examine the package.json for the caller's code, and will transitively walk * it's 'dependencies' section to determine what packages should be included. * * During this walk, if a package is encountered that contains a `"pulumi": { ... }` section then * the normal `"dependencies": { ... }` section of that package will not be included. These are * "pulumi" packages, and those dependencies are only intended for use at deployment time. However, * a "pulumi" package can also specify package that should be available at cloud-runtime. These * packages are found in a `"runtimeDependencies": { ... }` section in the package.json file with * the same format as the normal "dependencies" section. * * See [CodePathOptions] for information on ways to control and configure the final set of paths * included in the resultant asset/archive map. * * Note: this functionality is specifically intended for use by downstream library code that is * determining what is needed for a cloud-lambda. i.e. the aws.serverless.Function or * azure.serverless.FunctionApp libraries. In general, other clients should not need to use this * helper. */ export async function computeCodePaths(options?: CodePathOptions): Promise>; /** * @deprecated Use the [computeCodePaths] overload that takes a [CodePathOptions] instead. */ export async function computeCodePaths(extraIncludePaths?: string[], extraIncludePackages?: string[], extraExcludePackages?: string[]): Promise>; export async function computeCodePaths( optionsOrExtraIncludePaths?: string[] | CodePathOptions, extraIncludePackages?: string[], extraExcludePackages?: string[]): Promise> { let options: CodePathOptions; if (Array.isArray(optionsOrExtraIncludePaths)) { log.warn("'function computeCodePaths(string[])' is deprecated. Use the [computeCodePaths] overload that takes a [CodePathOptions] instead."); options = { extraIncludePaths: optionsOrExtraIncludePaths, extraIncludePackages, extraExcludePackages, }; } else { options = optionsOrExtraIncludePaths || {}; } return computeCodePathsWorker(options); } async function computeCodePathsWorker(options: CodePathOptions): Promise> { // Construct the set of paths to include in the archive for upload. // Find folders for all packages requested by the user. Note: all paths in this should // be normalized. const normalizedPathSet = await allFoldersForPackages( new Set(options.extraIncludePackages || []), new Set(options.extraExcludePackages || []), options.logResource); // Add all paths explicitly requested by the user const extraIncludePaths = options.extraIncludePaths || []; for (const path of extraIncludePaths) { normalizedPathSet.add(upath.normalize(path)); } const codePaths: Map = new Map(); // For each of the required paths, add the corresponding FileArchive or FileAsset to the // AssetMap. for (const normalizedPath of normalizedPathSet) { // Don't include a path if there is another path higher up that will include this one. if (isSubsumedByHigherPath(normalizedPath, normalizedPathSet)) { continue; } // The Asset model does not support a consistent way to embed a file-or-directory into an // `AssetArchive`, so we stat the path to figure out which it is and use the appropriate // Asset constructor. const stats = fs.statSync(normalizedPath); if (stats.isDirectory()) { codePaths.set(normalizedPath, new asset.FileArchive(normalizedPath)); } else { codePaths.set(normalizedPath, new asset.FileAsset(normalizedPath)); } } return codePaths; } function isSubsumedByHigherPath(normalizedPath: string, normalizedPathSet: Set): boolean { for (const otherNormalizedPath of normalizedPathSet) { if (normalizedPath.length > otherNormalizedPath.length && normalizedPath.startsWith(otherNormalizedPath)) { // Have to make sure we're actually a sub-directory of that other path. For example, // if we have: node_modules/mime-types, that's not subsumed by node_modules/mime const nextChar = normalizedPath.charAt(otherNormalizedPath.length); return nextChar === "/"; } } return false; } // allFolders computes the set of package folders that are transitively required by the root // 'dependencies' node in the client's project.json file. function allFoldersForPackages( includedPackages: Set, excludedPackages: Set, logResource: Resource | undefined): Promise> { return new Promise((resolve, reject) => { readPackageTree(".", undefined, (err: any, root: readPackageTree.Node) => { try { if (err) { return reject(err); } // read-package-tree defers to read-package-json to parse the project.json file. If that // fails, root.error is set to the underlying error. Unfortunately, read-package-json is // very finicky and can fail for reasons that are not relevant to us. For example, it // can fail if a "version" string is not a legal semver. We still want to proceed here // as this is not an actual problem for determining the set of dependencies. if (root.error) { if (!root.realpath) { throw new ResourceError( "Failed to parse package.json. Underlying issue:\n " + root.error.toString(), logResource); } // From: https://github.com/npm/read-package-tree/blob/5245c6e50d7f46ae65191782622ec75bbe80561d/rpt.js#L121 root.package = computeDependenciesDirectlyFromPackageFile( upath.join(root.realpath, "package.json"), logResource); } // This is the core starting point of the algorithm. We use readPackageTree to get // the package.json information for this project, and then we start by walking the // .dependencies node in that package. Importantly, we do not look at things like // .devDependencies or or .peerDependencies. These are not what are considered part // of the final runtime configuration of the app and should not be uploaded. const referencedPackages = new Set(includedPackages); if (root.package && root.package.dependencies) { for (const depName of Object.keys(root.package.dependencies)) { referencedPackages.add(depName); } } // package.json files can contain circularities. For example es6-iterator depends // on es5-ext, which depends on es6-iterator, which depends on es5-ext: // https://github.com/medikoo/es6-iterator/blob/0eac672d3f4bb3ccc986bbd5b7ffc718a0822b74/package.json#L20 // https://github.com/medikoo/es5-ext/blob/792c9051e5ad9d7671dd4e3957eee075107e9e43/package.json#L29 // // So keep track of the paths we've looked and don't recurse if we hit something again. const seenPaths = new Set(); const normalizedPackagePaths = new Set(); for (const pkg of referencedPackages) { addPackageAndDependenciesToSet( root, pkg, seenPaths, normalizedPackagePaths, excludedPackages); } return resolve(normalizedPackagePaths); } catch (error) { return reject(error); } }); }); } function computeDependenciesDirectlyFromPackageFile(path: string, logResource: Resource | undefined): any { // read the package.json file in directly. if any of these fail an error will be thrown // and bubbled back out to user. const contents = readFile(); const data = parse(); // 'normalize-package-data' can throw if 'version' isn't a valid string. We don't care about // 'version' so just delete it. // https://github.com/npm/normalize-package-data/blob/df8ea05b8cd38531e8b70ac7906f420344f55bab/lib/fixer.js#L191 delete data.version; // 'normalize-package-data' can throw if 'name' isn't a valid string. We don't care about // 'name' so just delete it. // https://github.com/npm/normalize-package-data/blob/df8ea05b8cd38531e8b70ac7906f420344f55bab/lib/fixer.js#L211 delete data.name; normalize(data); return data; function readFile() { try { return fs.readFileSync(path); } catch (err) { throw new ResourceError(`Error reading file '${path}' when computing package dependencies. ${err}`, logResource); } } function parse() { try { return JSON.parse(contents.toString()); } catch (err) { throw new ResourceError(`Error parsing file '${path}' when computing package dependencies. ${err}`, logResource); } } } // addPackageAndDependenciesToSet adds all required dependencies for the requested pkg name from the given root package // into the set. It will recurse into all dependencies of the package. function addPackageAndDependenciesToSet( root: readPackageTree.Node, pkg: string, seenPaths: Set, normalizedPackagePaths: Set, excludedPackages: Set) { // Don't process this packages if it was in the set the user wants to exclude. if (excludedPackages.has(pkg)) { return; } const child = findDependency(root, pkg); if (!child) { console.warn(`Could not include required dependency '${pkg}' in '${upath.resolve(root.path)}'.`); return; } // Don't process a child path if we've already encountered it. const normalizedPath = upath.normalize(child.path); if (seenPaths.has(normalizedPath)) { return; } seenPaths.add(normalizedPath); if (child.package.pulumi) { // This was a pulumi deployment-time package. Check if it had a: // // `pulumi: { runtimeDependencies: ... }` // // section. In this case, we don't want to add this specific package, but we do want to // include all the runtime dependencies it says are necessary. recurse(child.package.pulumi.runtimeDependencies); } else if (pkg.startsWith("@pulumi")) { // exclude it if it's an @pulumi package. These packages are intended for deployment // time only and will only bloat up the serialized lambda package. Note: this code can // be removed once all pulumi packages add a "pulumi" section to their package.json. return; } else { // Normal package. Add the normalized path to it, and all transitively add all of its // dependencies. normalizedPackagePaths.add(normalizedPath); recurse(child.package.dependencies); } return; function recurse(dependencies: any) { if (dependencies) { for (const dep of Object.keys(dependencies)) { addPackageAndDependenciesToSet( child!, dep, seenPaths, normalizedPackagePaths, excludedPackages); } } } } // findDependency searches the package tree starting at a root node (possibly a child) for a match // for the given name. It is assumed that the tree was correctly constructed such that dependencies // are resolved to compatible versions in the closest available match starting at the provided root // and walking up to the head of the tree. function findDependency(root: readPackageTree.Node | undefined | null, name: string): readPackageTree.Node | undefined { for (; root; root = root.parent) { for (const child of root.children) { let childName = child.name; // Note: `read-package-tree` returns incorrect `.name` properties for packages in an // organization - like `@types/express` or `@protobufjs/path`. Compute the correct name // from the `path` property instead. Match any name that ends with something that looks // like `@foo/bar`, such as `node_modules/@foo/bar` or // `node_modules/baz/node_modules/@foo/bar. const childFolderName = upath.basename(child.path); const parentFolderName = upath.basename(upath.dirname(child.path)); if (parentFolderName[0] === "@") { childName = upath.join(parentFolderName, childFolderName); } if (childName === name) { return child; } } } return undefined; }