UNPKG

genezio

Version:

Command line utility to interact with Genezio infrastructure.

399 lines (398 loc) 20.5 kB
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var _NodeJsBundler_instances, _NodeJsBundler_copyDependencies, _NodeJsBundler_copyNonJsFiles, _NodeJsBundler_bundleNodeJSCode, _NodeJsBundler_handleMissingDependencies, _NodeJsBundler_getDependenciesInfo; import path from "path"; import os from "os"; import fs from "fs"; import { createTemporaryFolder, deleteFolder, getAllFilesFromPath, writeToFile, } from "../../utils/file.js"; import { default as fsExtra } from "fs-extra"; import { lambdaHandlerGenerator } from "./lambdaHandlerGenerator.js"; import { genezioRuntimeHandlerGenerator } from "./genezioRuntimeHandlerGenerator.js"; import { log } from "../../utils/logging.js"; import { debugLogger } from "../../utils/logging.js"; import esbuild from "esbuild"; import { nodeExternalsPlugin } from "esbuild-node-externals"; import colors from "colors"; import { DependencyInstaller } from "./dependencyInstaller.js"; import { GENEZIO_NOT_ENOUGH_PERMISSION_FOR_FILE, UserError } from "../../errors.js"; import transformDecorators from "../../utils/transformDecorators.js"; import { spawnSync } from "child_process"; import { generateNodeContainerManifest } from "./containerManifest.js"; import { clusterWrapperCode } from "./clusterHandler.js"; import { CloudProviderIdentifier } from "../../models/cloudProviderIdentifier.js"; import { clusterHandlerGenerator } from "./clusterHandlerGenerator.js"; import { DEFAULT_NODE_RUNTIME } from "../../models/projectOptions.js"; export class NodeJsBundler { constructor() { _NodeJsBundler_instances.add(this); } getHandlerGeneratorForProvider(provider) { switch (provider) { case CloudProviderIdentifier.GENEZIO_CLUSTER: return clusterHandlerGenerator; case CloudProviderIdentifier.GENEZIO_AWS: return lambdaHandlerGenerator; case CloudProviderIdentifier.GENEZIO_UNIKERNEL: case CloudProviderIdentifier.GENEZIO_CLOUD: return genezioRuntimeHandlerGenerator; default: return null; } } async bundle(input) { const mode = input.extra.mode; const tmpFolder = input.extra.tmpFolder; const cwd = input.projectConfiguration.workspace?.backend || process.cwd(); const cloudProvider = input.projectConfiguration.cloudProvider; // TODO: Remove this check after cluster is fully supported in other regions if (cloudProvider === CloudProviderIdentifier.GENEZIO_CLUSTER && input.projectConfiguration.region !== "us-east-1") { throw new UserError(`While in ALPHA phase, persistent deployment is not supported in ${input.projectConfiguration.region} region. Please use us-east-1 region or use "genezio" as cloud provider.`); } if (mode === "development" && !tmpFolder) { throw new UserError("tmpFolder is required in development mode."); } const handlerGenerator = this.getHandlerGeneratorForProvider(cloudProvider); if (handlerGenerator === null) { throw new UserError(`Can't generate handler for cloud provider ${cloudProvider}.`); } const temporaryFolder = mode === "production" ? await createTemporaryFolder() : tmpFolder; input.extra.dependenciesInfo = []; // 1. Run esbuild to get dependenciesInfo and the bundled file debugLogger.debug(`[NodeJSBundler] Get the list of node modules and bundling the javascript code for file ${input.path}.`); await Promise.all([ __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_bundleNodeJSCode).call(this, input.configuration.path, temporaryFolder, input.projectConfiguration.workspace?.backend), mode === "development" ? __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_copyDependencies).call(this, undefined, temporaryFolder, mode, cwd) : Promise.resolve(), mode === "production" && input.extra.disableOptimization ? __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_copyDependencies).call(this, undefined, temporaryFolder, mode, cwd) : Promise.resolve(), mode === "production" && !input.extra.disableOptimization ? __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_getDependenciesInfo).call(this, input.configuration.path, input, cwd) : Promise.resolve(), ]); debugLogger.debug(`[NodeJSBundler] Copy non js files and node_modules for file ${input.path}.`); const isDeployedToCluster = input.projectConfiguration.cloudProvider === "cluster"; const nodeVersion = (input.projectConfiguration.options && "nodeRuntime" in input.projectConfiguration.options ? input.projectConfiguration.options.nodeRuntime : undefined) || DEFAULT_NODE_RUNTIME; // 2. Copy non js files and node_modules and write index.mjs file const entryFile = "index.mjs"; await Promise.all([ __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_copyNonJsFiles).call(this, temporaryFolder, input, cwd), mode === "production" ? __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_copyDependencies).call(this, input.extra.dependenciesInfo, temporaryFolder, mode, cwd) : Promise.resolve(), writeToFile(temporaryFolder, entryFile, handlerGenerator(input.configuration.name)), ...(isDeployedToCluster ? [ writeToFile(temporaryFolder, "local.mjs", clusterWrapperCode, true), writeToFile(temporaryFolder, "Dockerfile", generateNodeContainerManifest(nodeVersion), true), ] : []), ]); if (isDeployedToCluster && mode === "production") { log.info("Writing docker file for container packaging and building image"); // build image const dockerBuildProcess = spawnSync("docker", [ "buildx", "build", "--load", "--platform=linux/amd64", "-t", input.projectConfiguration.name + "-" + input.configuration.name.toLowerCase(), temporaryFolder || ".", ], { cwd: temporaryFolder }); if (dockerBuildProcess.status !== 0) { throw new Error(`Container image build failed, Docker daemon returned the following error: [${dockerBuildProcess.output}]`); } log.info("Container image successfully built"); } return { ...input, path: temporaryFolder, extra: { ...input.extra, originalPath: input.path, dependenciesInfo: input.extra.dependenciesInfo, allNonJsFilesPaths: input.extra.allNonJsFilesPaths, entryFile, }, }; } } _NodeJsBundler_instances = new WeakSet(), _NodeJsBundler_copyDependencies = async function _NodeJsBundler_copyDependencies(dependenciesInfo, tempFolderPath, mode, cwd) { const nodeModulesPath = path.join(tempFolderPath, "node_modules"); if (mode === "development") { // copy node_modules folder to tmp folder if node_modules folder does not exist if (!fs.existsSync(nodeModulesPath) && fs.existsSync(path.join(cwd, "node_modules"))) { await fsExtra.copy(path.join(cwd, "node_modules"), nodeModulesPath); } return; } // Copy all dependencies from node_modules folder to tmp/node_modules folder if (!dependenciesInfo) { await fsExtra.copy(path.join(cwd, "node_modules"), nodeModulesPath); return; } // Copy only required dependencies from node_modules folder to tmp/node_modules folder await Promise.all(dependenciesInfo.map((dependency) => { const dependencyPath = path.join(nodeModulesPath, dependency.name); return fsExtra.copy(dependency.path, dependencyPath); })); }, _NodeJsBundler_copyNonJsFiles = async function _NodeJsBundler_copyNonJsFiles(tempFolderPath, bundlerInput, cwd) { const allNonJsFilesPaths = (await getAllFilesFromPath(cwd)).filter((file) => { // create a regex to match any .env files const envFileRegex = new RegExp(/\.env(\..+)?$/); const folderPath = path.join(cwd, file.path); // filter js files, node_modules and folders return (file.extension !== ".ts" && file.extension !== ".js" && file.extension !== ".mjs" && file.extension !== ".cjs" && file.extension !== ".tsx" && file.extension !== ".jsx" && !file.path.includes("node_modules") && !file.path.includes(".git") && !envFileRegex.test(file.path) && !fs.lstatSync(folderPath).isDirectory()); }); bundlerInput.extra.allNonJsFilesPaths = allNonJsFilesPaths; // iterate over all non js files and copy them to tmp folder await Promise.all(allNonJsFilesPaths.map((filePath) => { // create folder structure in tmp folder const folderPath = path.join(tempFolderPath, path.dirname(filePath.path)); if (!fs.existsSync(folderPath)) { fs.mkdirSync(folderPath, { recursive: true }); } // copy file to tmp folder const fileDestinationPath = path.join(tempFolderPath, filePath.filename); const sourceFilePath = path.join(cwd, filePath.path); return fs.promises.copyFile(sourceFilePath, fileDestinationPath).catch((error) => { if (error.code === "EACCES") { throw new UserError(GENEZIO_NOT_ENOUGH_PERMISSION_FOR_FILE(filePath.path)); } throw error; }); })); }, _NodeJsBundler_bundleNodeJSCode = async function _NodeJsBundler_bundleNodeJSCode(filePath, tempFolderPath, cwd = process.cwd()) { const outputFile = `module.mjs`; // delete module.js file if it exists if (fs.existsSync(path.join(tempFolderPath, outputFile))) { fs.unlinkSync(path.join(tempFolderPath, outputFile)); } // Esbuild plugin that appends the following lines to the top of the file after it is bundled: // import { createRequire } from 'module'; // const require = createRequire(import.meta.url); const supportRequireInESM = { name: "esbuild-require-plugin", setup(build) { build.onLoad({ filter: /\.m?[jt]sx?$/ }, async (args) => { function getLoader(extension) { switch (extension) { case "ts": case "mts": return "ts"; case "tsx": case "mtsx": return "tsx"; case "js": case "mjs": return "js"; case "jsx": case "mjsx": return "jsx"; default: return "js"; } } const relativePath = path.relative(cwd, args.path); const components = relativePath.split(path.sep); const contents = await fs.promises.readFile(args.path, "utf8"); const loader = getLoader(args.path.split(".").pop()); // Check if file comes from node_modules if (components.length >= 1 && components.includes("node_modules")) { return { contents, loader }; } // Check if file doesn't use require() if (!contents.includes("require(")) { return { contents, loader }; } // Check if file uses require() for relative paths const regex = /require\(['"]\.\.?(?:\/[\w.-]+)+['"]\);?/g; const lineContents = contents.split(os.EOL); for (let i = 0; i < lineContents.length; i++) { const line = lineContents[i]; if (regex.test(line)) { return { errors: [ { text: `genezio does not support require() for relative paths. Please use import statements instead. For example: "const a = require("./b");" should be "import a from "./b";" or "const a = await import("./b");"`, location: { file: args.path, namespace: "file", lineText: line, line: i + 1, }, }, ], }; } } return { contents: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); ${contents}`, loader, }; }); }, }; const outputFilePath = path.join(tempFolderPath, outputFile); /* * ESBuild uses `package.json` file to determine which modules are external. * If the file is not provided, all packages will be bundled and none will be external. * We don't want this because it increases the size of the bundle. So, if * the `package.json` file exists, we inform ESBuild about it. */ let nodeExternalPlugin; if (fs.existsSync(path.join(cwd, "package.json"))) { nodeExternalPlugin = nodeExternalsPlugin({ packagePath: path.join(cwd, "package.json"), }); } else { nodeExternalPlugin = nodeExternalsPlugin(); } // eslint-disable-next-line no-async-promise-executor const output = await esbuild.build({ entryPoints: [filePath], bundle: true, metafile: true, format: "esm", platform: "node", outfile: outputFilePath, plugins: [nodeExternalPlugin, supportRequireInESM], sourcemap: "inline", sourcesContent: false, }); if (output.errors.length > 0) { // eslint-disable-next-line @typescript-eslint/no-explicit-any output.errors.forEach((error) => { log.error("\x1b[31m", "Syntax error:"); if (error.moduleIdentifier?.includes("|")) { log.info("\x1b[37m", "file: " + error.moduleIdentifier?.split("|")[1] + ":" + error.loc?.split(":")[0]); } else { log.info("file: " + error.moduleIdentifier + ":" + error.loc?.split(":")[0]); } // get first line of error const firstLine = error.message.split("\n")[0]; log.info(firstLine); //get message line that contains '>' first character const messageLine = error.message .split("\n") // eslint-disable-next-line @typescript-eslint/no-explicit-any .filter((line) => line.startsWith(">") || line.startsWith("|")) .join("\n"); if (messageLine) { log.info(messageLine); } }); throw "Compilation failed"; } const transformedCode = await transformDecorators(outputFilePath); fs.writeFileSync(outputFilePath, transformedCode); }, _NodeJsBundler_handleMissingDependencies = async function _NodeJsBundler_handleMissingDependencies(error, try_count, MAX_TRIES, cwd) { // If there is a build failure, check if it is caused by missing library dependencies // If it is, install them and try again const resolveRegex = /Could not resolve "(?<dependencyName>.+)"/; let npmInstallRequired = false; const errToDeps = error.errors.map((error) => { const regexGroups = resolveRegex.exec(error.text)?.groups; const packageName = regexGroups ? regexGroups["dependencyName"] : undefined; if (packageName && !error.location?.file.includes("node_modules/")) { npmInstallRequired = true; return null; } return packageName; }); const libraryDependencies = errToDeps.filter((dependencyName) => !!dependencyName); if (try_count >= MAX_TRIES) { if (libraryDependencies.length > 0 || npmInstallRequired) { log.info(`You have some missing dependencies. If you want to install them automatically, please run with ${colors.green("--install-deps")} flag`); } throw error; } const dependencyInstaller = new DependencyInstaller(); if (npmInstallRequired) { await dependencyInstaller.installAll(cwd); } if (libraryDependencies.length > 0) { await dependencyInstaller.install(libraryDependencies, cwd, true); } if (errToDeps.some((dependencyName) => dependencyName === undefined)) { throw error; } }, _NodeJsBundler_getDependenciesInfo = async function _NodeJsBundler_getDependenciesInfo(filePath, bundlerInput, cwd) { const tempFolderPath = await createTemporaryFolder(); let output; let try_count = 0; let MAX_TRIES = -1; if (bundlerInput.extra.installDeps) { MAX_TRIES = 2; } // eslint-disable-next-line no-constant-condition while (true) { try { // Building with `metafile` field set to true will return a JSON object with information about the dependencies output = esbuild.buildSync({ entryPoints: [filePath], bundle: true, metafile: true, platform: "node", outfile: path.join(tempFolderPath, "module.mjs"), logLevel: "silent", sourcemap: "inline", }); break; } catch (error) { await __classPrivateFieldGet(this, _NodeJsBundler_instances, "m", _NodeJsBundler_handleMissingDependencies).call(this, error, try_count, MAX_TRIES, cwd); } try_count++; } if (output.metafile === undefined) { throw new UserError("Could not get dependencies info"); } const dependencyMap = new Map(); Object.keys(output.metafile.inputs).forEach((value) => { // We are filtering out all the node_modules that are resolved outside of the // genezio backend framework. This is because we had problems in the past // where bundler was returning deps that were causing errors. if (value.startsWith("..") || !value.includes("node_modules")) { return; } // We use '/' as a separator regardless the platform because esbuild returns '/' separated paths // The name of the dependency is right after the "node_modules" component. const components = value.split("/"); const dependencyName = components[components.indexOf("node_modules") + 1]; const dependencyPath = path.resolve(path.join(cwd, "node_modules", dependencyName)); // This should not ever happen. If you got here... Good luck! const existingDependencyPath = dependencyMap.get(dependencyName); if (existingDependencyPath !== undefined && existingDependencyPath !== dependencyPath) { throw new UserError(`Dependency ${dependencyName} has two different paths: ${existingDependencyPath} and ${dependencyPath}`); } dependencyMap.set(dependencyName, dependencyPath); }); bundlerInput.extra.dependenciesInfo = Array.from(dependencyMap.entries()).map(([name, path]) => ({ name, path })); await deleteFolder(tempFolderPath); };