app-builder-lib
Version:
electron-builder lib
414 lines • 20 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeModulesCollector = void 0;
const builder_util_1 = require("builder-util");
const childProcess = require("child_process");
const fs = require("fs-extra");
const fs_extra_1 = require("fs-extra");
const lazy_val_1 = require("lazy-val");
const path = require("path");
const semver = require("semver");
const hoist_1 = require("./hoist");
const moduleCache_1 = require("./moduleCache");
const packageManager_1 = require("./packageManager");
class NodeModulesCollector {
constructor(rootDir, tempDirManager) {
this.rootDir = rootDir;
this.tempDirManager = tempDirManager;
this.nodeModules = [];
this.allDependencies = new Map();
this.productionGraph = {};
this.cache = new moduleCache_1.ModuleCache();
this.isHoisted = new lazy_val_1.Lazy(async () => {
const { manager } = this.installOptions;
const command = (0, packageManager_1.getPackageManagerCommand)(manager);
const config = (await this.asyncExec(command, ["config", "list"])).stdout;
if (config == null) {
builder_util_1.log.debug({ manager }, "unable to determine if node_modules are hoisted: no config output. falling back to hoisted mode");
return false;
}
const lines = Object.fromEntries(config.split("\n").map(line => line.split("=").map(s => s.trim())));
if (lines["node-linker"] === "hoisted") {
builder_util_1.log.debug({ manager }, "node_modules are hoisted");
return true;
}
return false;
});
}
async getNodeModules({ cancellationToken, packageName }) {
const tree = await this.getDependenciesTree(this.installOptions.manager);
if (cancellationToken.cancelled) {
throw new Error("getNodeModules cancelled after fetching dependency tree");
}
await this.collectAllDependencies(tree, packageName);
const realTree = await this.getTreeFromWorkspaces(tree, packageName);
await this.extractProductionDependencyGraph(realTree, packageName);
if (cancellationToken.cancelled) {
throw new Error("getNodeModules cancelled after building production graph");
}
const hoisterResult = (0, hoist_1.hoist)(this.transformToHoisterTree(this.productionGraph, packageName), {
check: builder_util_1.log.isDebugEnabled,
});
await this._getNodeModules(hoisterResult.dependencies, this.nodeModules);
builder_util_1.log.debug({ packageName, depCount: this.nodeModules.length }, "node modules collection complete");
return this.nodeModules;
}
async getDependenciesTree(pm) {
const command = (0, packageManager_1.getPackageManagerCommand)(pm);
const args = this.getArgs();
const tempOutputFile = await this.tempDirManager.getTempFile({
prefix: path.basename(command, path.extname(command)),
suffix: "output.json",
});
return (0, builder_util_1.retry)(async () => {
await this.streamCollectorCommandToFile(command, args, this.rootDir, tempOutputFile);
const shellOutput = await fs.readFile(tempOutputFile, { encoding: "utf8" });
return await this.parseDependenciesTree(shellOutput.trim());
}, {
retries: 1,
interval: 2000,
backoff: 2000,
shouldRetry: async (error) => {
var _a;
const logFields = { error: error.message, tempOutputFile, cwd: this.rootDir };
if (!(await this.cache.exists[tempOutputFile])) {
builder_util_1.log.debug(logFields, "dependency tree output file missing, retrying");
return true;
}
const fileContent = await fs.readFile(tempOutputFile, { encoding: "utf8" });
const fields = { ...logFields, fileContent };
if (fileContent.trim().length === 0) {
builder_util_1.log.debug(fields, "dependency tree output file empty, retrying");
return true;
}
if ((_a = error.message) === null || _a === void 0 ? void 0 : _a.includes("Unexpected end of JSON input")) {
builder_util_1.log.debug(fields, "JSON parse error in dependency tree, retrying");
return true;
}
builder_util_1.log.error(fields, "error parsing dependencies tree");
return false;
},
});
}
cacheKey(pkg) {
const rel = path.relative(this.rootDir, pkg.path);
return `${pkg.name}::${pkg.version}::${rel !== null && rel !== void 0 ? rel : "."}`;
}
packageVersionString(pkg) {
return `${pkg.name}@${pkg.version}`;
}
isProdDependency(depName, pkg) {
const prodDeps = { ...pkg.dependencies, ...pkg.optionalDependencies };
return prodDeps[depName] != null;
}
/**
* Parse a dependency identifier like "@scope/pkg@1.2.3" or "pkg@1.2.3"
*/
parseNameVersion(identifier) {
const lastAt = identifier.lastIndexOf("@");
if (lastAt <= 0) {
// fallback for scoped packages or malformed strings
return { name: identifier, version: "unknown" };
}
const name = identifier.slice(0, lastAt);
const version = identifier.slice(lastAt + 1);
return { name, version };
}
async getTreeFromWorkspaces(tree, packageName) {
var _a;
if (!(tree.workspaces && tree.dependencies)) {
return tree;
}
if ((_a = tree.dependencies) === null || _a === void 0 ? void 0 : _a[packageName]) {
const { name, path, dependencies } = tree.dependencies[packageName];
builder_util_1.log.debug({ name, path, dependencies: JSON.stringify(dependencies) }, "pruning root app/self reference from workspace tree");
for (const [name, pkg] of Object.entries(dependencies !== null && dependencies !== void 0 ? dependencies : {})) {
tree.dependencies[name] = pkg;
this.allDependencies.set(this.packageVersionString(pkg), pkg);
}
delete tree.dependencies[packageName];
}
return Promise.resolve(tree);
}
transformToHoisterTree(obj, key, nodes = new Map()) {
let node = nodes.get(key);
const { name, version } = this.parseNameVersion(key);
if (!node) {
node = {
name,
identName: name,
reference: version,
dependencies: new Set(),
peerNames: new Set(),
};
nodes.set(key, node);
const deps = (obj[key] || {}).dependencies || [];
for (const dep of deps) {
const child = this.transformToHoisterTree(obj, dep, nodes);
node.dependencies.add(child);
}
}
return node;
}
async _getNodeModules(dependencies, result) {
var _a;
if (dependencies.size === 0) {
return;
}
for (const d of dependencies.values()) {
const reference = [...d.references][0];
const p = (_a = this.allDependencies.get(`${d.name}@${reference}`)) === null || _a === void 0 ? void 0 : _a.path;
if (p === undefined) {
builder_util_1.log.warn({ name: d.name, reference }, "cannot find path for dependency");
continue;
}
// fix npm list issue
// https://github.com/npm/cli/issues/8535
if (!(await this.cache.exists[p])) {
builder_util_1.log.debug({ name: d.name, reference, p }, "dependency path does not exist");
continue;
}
const node = {
name: d.name,
version: reference,
dir: await this.cache.realPath[p],
};
result.push(node);
if (d.dependencies.size > 0) {
node.dependencies = [];
await this._getNodeModules(d.dependencies, node.dependencies);
}
}
result.sort((a, b) => a.name.localeCompare(b.name));
}
async asyncExec(command, args, cwd = this.rootDir) {
const file = await this.tempDirManager.getTempFile({ prefix: "exec-", suffix: ".txt" });
try {
await this.streamCollectorCommandToFile(command, args, cwd, file);
const result = await fs.readFile(file, { encoding: "utf8" });
return { stdout: result === null || result === void 0 ? void 0 : result.trim(), stderr: undefined };
}
catch (error) {
builder_util_1.log.debug({ error: error.message }, "failed to execute command");
return { stdout: undefined, stderr: error.message };
}
}
async streamCollectorCommandToFile(command, args, cwd, tempOutputFile) {
const execName = path.basename(command, path.extname(command));
const isWindowsScriptFile = process.platform === "win32" && path.extname(command).toLowerCase() === ".cmd";
if (isWindowsScriptFile) {
// If the command is a Windows script file (.cmd), we need to wrap it in a .bat file to ensure it runs correctly with cmd.exe
// This is necessary because .cmd files are not directly executable in the same way as .bat files.
// We create a temporary .bat file that calls the .cmd file with the provided arguments. The .bat file will be executed by cmd.exe.
// Note: This is a workaround for Windows command execution quirks for specifically when `shell: false`
const tempBatFile = await this.tempDirManager.getTempFile({
prefix: execName,
suffix: ".bat",
});
const batScript = ` off\r\n"${command}" %*\r\n`; // <-- CRLF required for .bat
await fs.writeFile(tempBatFile, batScript, { encoding: "utf8" });
command = "cmd.exe";
args = ["/c", tempBatFile, ...args];
}
await new Promise((resolve, reject) => {
const outStream = (0, fs_extra_1.createWriteStream)(tempOutputFile);
const child = childProcess.spawn(command, args, {
cwd,
env: { COREPACK_ENABLE_STRICT: "0", ...process.env }, // allow `process.env` overrides
shell: false, // required to prevent console logs polution from shell profile loading when `true`
});
let stderr = "";
child.stdout.pipe(outStream);
child.stderr.on("data", chunk => {
stderr += chunk.toString();
});
child.on("error", err => {
reject(new Error(`Node module collector spawn failed: ${err.message}`));
});
child.on("close", code => {
outStream.close();
// https://github.com/npm/npm/issues/17624
const shouldIgnore = code === 1 && "npm" === execName.toLowerCase() && args.includes("list");
if (shouldIgnore) {
builder_util_1.log.debug(null, "`npm list` returned non-zero exit code, but it MIGHT be expected (https://github.com/npm/npm/issues/17624). Check stderr for details.");
}
if (stderr.length > 0) {
builder_util_1.log.debug({ stderr }, "note: there was node module collector output on stderr");
}
const shouldResolve = code === 0 || shouldIgnore;
return shouldResolve ? resolve() : reject(new Error(`Node module collector process exited with code ${code}:\n${stderr}`));
});
});
}
async locatePackageVersion(parentDir, pkgName, requiredRange) {
// 1) check direct parent node_modules/pkgName first
const direct = path.join(path.resolve(parentDir), "node_modules", pkgName, "package.json");
if (await this.cache.exists[direct]) {
const ver = await this.readPackageVersion(direct);
if (ver && this.semverSatisfies(ver, requiredRange)) {
return { packageDir: path.dirname(direct), version: ver };
}
}
// 2) upward hoisted search, then 3) downward non-hoisted search
return (await this.upwardSearch(parentDir, pkgName, requiredRange)) || (await this.downwardSearch(parentDir, pkgName, requiredRange)) || null;
}
async readPackageVersion(pkgJsonPath) {
return await this.cache.packageJson[pkgJsonPath].then(pkg => pkg.version).catch(() => null);
}
semverSatisfies(found, range) {
if (!range || range === "*" || range === "") {
return true;
}
if (range === found) {
return true;
}
if (semver.validRange(range) == null) {
// ignore, we can't verify non-semver ranges
// e.g. git urls, file:, patch:, etc. Example:
// "@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch"
builder_util_1.log.debug({ found, range }, "unable to validate semver version range, assuming match");
return true;
}
try {
return semver.satisfies(found, range);
}
catch {
// fallback: simple equality or basic prefix handling (^, ~)
if (range.startsWith("^") || range.startsWith("~")) {
const r = range.slice(1);
return r === found;
}
// if range is like "8.x" or "8.*" match major
const m = range.match(/^(\d+)[.(*|x)]*/);
const fm = found.match(/^(\d+)\./);
if (m && fm) {
return m[1] === fm[1];
}
return false;
}
}
/**
* Upward search (hoisted)
*/
async upwardSearch(parentDir, pkgName, requiredRange) {
let current = path.resolve(parentDir);
const root = path.parse(current).root;
while (true) {
const candidate = path.join(current, "node_modules", pkgName, "package.json");
if (await this.cache.exists[candidate]) {
const ver = await this.readPackageVersion(candidate);
if (ver && this.semverSatisfies(ver, requiredRange)) {
return { packageDir: path.dirname(candidate), version: ver };
}
// otherwise keep searching upward (we may find a different hoisted version)
}
if (current === root) {
break;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return null;
}
/**
* Breadth-first downward search from parentDir/node_modules
* Looks for node_modules/\*\/node_modules/pkgName (and deeper)
*/
async downwardSearch(parentDir, pkgName, requiredRange, maxExplored = 2000, maxDepth = 6) {
const start = path.join(path.resolve(parentDir), "node_modules");
if (!(await this.cache.exists[start]) || !(await this.cache.lstat[start]).isDirectory()) {
return null;
}
const visited = new Set();
const queue = [{ dir: start, depth: 0 }];
let explored = 0;
while (queue.length > 0) {
const { dir, depth } = queue.shift();
if (explored++ > maxExplored) {
break;
}
if (depth > maxDepth) {
continue;
}
let entries;
try {
entries = await fs.readdir(dir);
}
catch {
continue;
}
for (const entry of entries) {
if (entry.startsWith(".")) {
continue;
}
const entryPath = path.join(dir, entry);
// handle scoped packages @scope/name
if (entry.startsWith("@")) {
// queue the scope directory itself to explore its children
if ((await this.cache.exists[entryPath]) && (await this.cache.lstat[entryPath]).isDirectory()) {
const scopeEntries = await fs.readdir(entryPath);
for (const sc of scopeEntries) {
const scPath = path.join(entryPath, sc);
// check scPath/node_modules/pkgName
const candidatePkgJson = path.join(scPath, "node_modules", pkgName, "package.json");
if (await this.cache.exists[candidatePkgJson]) {
const ver = await this.readPackageVersion(candidatePkgJson);
if (ver && this.semverSatisfies(ver, requiredRange)) {
return { packageDir: path.dirname(candidatePkgJson), version: ver };
}
}
// enqueue scPath/node_modules to explore further
const scNodeModules = path.join(scPath, "node_modules");
if ((await this.cache.exists[scNodeModules]) && (await this.cache.lstat[scNodeModules]).isDirectory()) {
if (!visited.has(scNodeModules)) {
visited.add(scNodeModules);
queue.push({ dir: scNodeModules, depth: depth + 1 });
}
}
}
}
continue;
}
// check for direct candidate: entry/node_modules/pkgName
try {
const stat = await this.cache.lstat[entryPath];
if (!stat.isDirectory()) {
continue;
}
}
catch {
continue;
}
const candidatePkgJson = path.join(entryPath, "node_modules", pkgName, "package.json");
if (await this.cache.exists[candidatePkgJson]) {
const ver = await this.readPackageVersion(candidatePkgJson);
if (ver && this.semverSatisfies(ver, requiredRange)) {
return { packageDir: path.dirname(candidatePkgJson), version: ver };
}
}
// also check entry/node_modules directly for pkgName (some layouts)
const candidateDirect = path.join(entryPath, pkgName, "package.json");
if (await this.cache.exists[candidateDirect]) {
const ver = await this.readPackageVersion(candidateDirect);
if (ver && this.semverSatisfies(ver, requiredRange)) {
return { packageDir: path.dirname(candidateDirect), version: ver };
}
}
// enqueue entry/node_modules for deeper traversal
const nextNodeModules = path.join(entryPath, "node_modules");
if ((await this.cache.exists[nextNodeModules]) && (await this.cache.lstat[nextNodeModules]).isDirectory()) {
if (!visited.has(nextNodeModules)) {
visited.add(nextNodeModules);
queue.push({ dir: nextNodeModules, depth: depth + 1 });
}
}
}
}
return null;
}
}
exports.NodeModulesCollector = NodeModulesCollector;
//# sourceMappingURL=nodeModulesCollector.js.map