genezio
Version:
Command line utility to interact with Genezio infrastructure.
410 lines (409 loc) • 17 kB
JavaScript
import path from "path";
import { SSRFrameworkComponentType } from "../../models/projectOptions.js";
import { YamlConfigurationIOController } from "../../projectConfiguration/yaml/v2.js";
import { FRONTEND_ENV_PREFIX } from "./command.js";
import { Language } from "../../projectConfiguration/yaml/models.js";
export async function addFrontendComponentToConfig(configPath, component) {
const configIOController = new YamlConfigurationIOController(configPath);
// We have to read the config here with fillDefaults=false
// to be able to edit it in the least intrusive way
const config = await configIOController.read(/* fillDefaults= */ false);
// Ensure each script field is an array
// It's easier to use arrays consistently instead of
// having to check if it's a string or an array
const scripts = component.scripts;
if (scripts) {
normalizeScripts(scripts);
}
// Update the existing frontend component only with the fields that are not set
// If the user set a specific field in the `genezio.yaml` we are going to assume is intentional
let frontend = config.frontend;
frontend = {
...config.frontend,
path: frontend?.path || component.path,
publish: frontend?.publish || component.publish,
scripts: {
deploy: frontend?.scripts?.deploy || scripts?.deploy,
build: frontend?.scripts?.build || scripts?.build,
start: frontend?.scripts?.start || scripts?.start,
},
};
config.frontend = frontend;
await configIOController.write(config);
}
export async function addBackendComponentToConfig(configPath, component) {
const configIOController = new YamlConfigurationIOController(configPath);
// We have to read the config here with fillDefaults=false
// to be able to edit it in the least intrusive way
const config = await configIOController.read(/* fillDefaults= */ false);
// Ensure each script field is an array
// It's easier to use arrays consistently instead of
// having to check if it's a string or an array
const scripts = component.scripts;
if (scripts) {
normalizeScripts(scripts);
}
// Update the existing frontend component only with the fields that are not set
// If the user set a specific field in the `genezio.yaml` we are going to assume is intentional
let backend = config.backend;
backend = {
...config.backend,
path: backend?.path || component.path,
language: backend?.language || component.language,
functions: backend?.functions || component.functions,
environment: {
...component.environment,
...backend?.environment,
},
scripts: {
deploy: backend?.scripts?.deploy || scripts?.deploy,
local: backend?.scripts?.local || scripts?.local,
},
};
config.backend = backend;
await configIOController.write(config);
}
export async function addSSRComponentToConfig(configPath, component, componentType) {
const configIOController = new YamlConfigurationIOController(configPath);
// We have to read the config here with fillDefaults=false
// to be able to edit it in the least intrusive way
const config = await configIOController.read(/* fillDefaults= */ false);
const relativePath = path.relative(process.cwd(), component.path) || ".";
// Ensure each script field is an array
// It's easier to use arrays consistently instead of
// having to check if it's a string or an array
const scripts = component.scripts;
if (scripts) {
normalizeScripts(scripts);
}
config[componentType] = {
...config[componentType],
path: config[componentType]?.path || relativePath,
packageManager: config[componentType]?.packageManager || component.packageManager,
environment: {
...component.environment,
...config[componentType]?.environment,
},
scripts: config[componentType]?.scripts || component.scripts,
entryFile: config[componentType]?.entryFile || component.entryFile,
runtime: config[componentType]?.runtime || component.runtime,
};
await configIOController.write(config);
}
export async function addContainerComponentToConfig(configPath, component) {
const configIOController = new YamlConfigurationIOController(configPath);
// We have to read the config here with fillDefaults=false
// to be able to edit it in the least intrusive way
const config = await configIOController.read(/* fillDefaults= */ false);
const relativePath = path.relative(process.cwd(), component.path) || ".";
config.container = {
...config.container,
path: config.container?.path || relativePath,
};
await configIOController.write(config);
}
export async function addServicesToConfig(configPath, services) {
const configIOController = new YamlConfigurationIOController(configPath);
// We have to read the config here with fillDefaults=false
// to be able to edit it in the least intrusive way
const config = await configIOController.read(/* fillDefaults= */ false);
config.services = config.services || {};
// Ensure unique types are added
const existingDatabases = config.services.databases || [];
const newDatabases = services.databases || [];
// Add only new database types
const mergedDatabases = [
...existingDatabases,
...newDatabases.filter((db) => !existingDatabases.some((existingDb) => existingDb.type === db.type)),
];
// Update services with the merged databases
config.services.databases = mergedDatabases;
await configIOController.write(config);
return config;
}
// TODO - Remove this method when support for functions (express, flask etc) with nextjs, nuxt, remix is added
export async function handleBackendAndSSRConfig(configPath) {
const configIOController = new YamlConfigurationIOController(configPath);
// Load configuration with minimal changes
const config = await configIOController.read(/* fillDefaults= */ false);
// Remove SSR-related framework configurations
const ssrFrameworks = Object.values(SSRFrameworkComponentType);
for (const framework of ssrFrameworks) {
config[framework] = undefined;
}
// Save the updated configuration
await configIOController.write(config);
}
/**
* Injects the backend function URLs like express, flask (backend functions), nestjs and nitro
* into the frontend environment variables like react, vue, angular, nextjs and nuxt.
*
* @param frontendPrefix The prefix to use for the frontend environment variables.
* @param configPath The path to the config file.
*/
export async function injectBackendUrlsInConfig(configPath) {
const configIOController = new YamlConfigurationIOController(configPath);
// Load config with minimal changes
const config = await configIOController.read(/* fillDefaults= */ false);
const backend = config.backend;
const functions = backend?.functions?.map((fn) => fn.name) || [];
const ssrFunctions = [];
const ssrFrameworks = [SSRFrameworkComponentType.nestjs, SSRFrameworkComponentType.nitro];
ssrFrameworks.forEach((framework) => {
if (config[framework]) {
ssrFunctions.push(framework);
}
});
// Early return if there is nothing to inject
if (functions.length === 0 && ssrFunctions.length === 0) {
return;
}
// Generate frontend environment variables based on backend functions
const frontend = config.frontend;
if (frontend) {
// TODO Support multiple frontend frameworks in the same project
const frontendPrefix = getFrontendPrefix(Object.keys(frontend)[0]);
const frontendEnvironment = {
...createFrontendEnvironmentRecord(frontendPrefix, functions, ssrFunctions),
...frontend.environment,
};
frontend.environment = frontendEnvironment;
config.frontend = frontend;
}
// TODO - Uncomment this when support for functions (express, flask etc) with nextjs, nuxt, remix is added
// const frameworks = [
// { key: SSRFrameworkComponentType.next, prefix: "NEXT_PUBLIC" },
// { key: SSRFrameworkComponentType.nuxt, prefix: "NUXT" },
// ];
// frameworks.forEach(({ key, prefix }) => {
// if (config[key]) {
// const environment = {
// ...createFrontendEnvironmentRecord(prefix, functions, ssrFunctions),
// ...config[key].environment,
// };
// config[key].environment = environment;
// }
// });
// Save the updated configuration
await configIOController.write(config);
}
/**
* Injects the SDK into the backend configuration.
* @param configPath The path to the config file.
*/
export async function injectSDKInConfig(configPath) {
const configIOController = new YamlConfigurationIOController(configPath);
// Load config with minimal changes
const config = await configIOController.read(/* fillDefaults= */ false);
const frontend = config.frontend;
// TODO - Add support for other languages
frontend.sdk = {
language: Language.ts,
};
const scripts = frontend.scripts;
if (scripts) {
normalizeScripts(scripts);
}
// Check if scripts has the deploy and build properties for typesafe projects
// If not append them to the scripts object
frontend.scripts = {
...scripts,
deploy: [
...(frontend.scripts?.deploy?.includes("npm install @genezio-sdk/${{projectName}}@1.0.0-${{stage}}")
? []
: ["npm install @genezio-sdk/${{projectName}}@1.0.0-${{stage}}"]),
...(frontend.scripts?.deploy?.includes("npm install") ? [] : ["npm install"]),
...(frontend.scripts?.deploy || []),
],
build: frontend.scripts?.build || ["npm run build"],
};
// Save the updated configuration
await configIOController.write(config);
}
/**
* Returns the frontend environment variable prefix based on the frontend framework.
*
* @param framework
* @returns
*/
export function getFrontendPrefix(framework) {
switch (framework.toLowerCase()) {
case "react":
return FRONTEND_ENV_PREFIX.React;
case "vue":
return FRONTEND_ENV_PREFIX.Vue;
case "vite":
return FRONTEND_ENV_PREFIX.Vite;
default:
return FRONTEND_ENV_PREFIX.Vite;
}
}
/**
* Creates a Record<string, string> where each key represents a frontend environment
* variable based on the backend function name and each value is the function's URL.
*/
export function createFrontendEnvironmentRecord(frontendPrefix, backendFunctions = [], ssrFrameworks = []) {
const environment_backend = backendFunctions.reduce((environment, functionName) => {
const key = formatFunctionNameAsEnvVar(frontendPrefix, functionName);
const value = `\${{ backend.functions.${functionName}.url }}`;
environment[key] = value;
return environment;
}, {});
const environment_ssr = ssrFrameworks.reduce((environment, framework) => {
const key = `${frontendPrefix}_API_URL_${framework.toUpperCase()}`;
const value = `\${{ ${framework}.url }}`;
environment[key] = value;
return environment;
}, {});
return { ...environment_backend, ...environment_ssr };
}
/**
* Formats a backend function name into an uppercase environment variable key.
*/
function formatFunctionNameAsEnvVar(prefix, functionName) {
return `${prefix}_API_URL_${functionName.toUpperCase().replace(/-/g, "_")}`;
}
function normalizeScriptProperty(scripts, property) {
if (scripts[property] && typeof scripts[property] === "string") {
scripts[property] = [scripts[property]];
}
}
function normalizeScripts(scripts) {
if (scripts) {
const properties = ["deploy", "build", "start", "local"];
properties.forEach((property) => {
normalizeScriptProperty(scripts, property);
});
}
}
/**
* Returns the handler for a given python framework.
* Searches for framework initialization patterns in Python code.
* @param contentEntryfile Content of the entry file
* @returns The handler variable name or undefined if not found
*/
export function getPythonHandler(contentEntryfile) {
// Check if the contentEntryfile contains Flask or FastAPI initialization
const flaskPattern = /(\w+)\s*=\s*Flask\(__name__\)/;
const fastAPIPattern = /(\w+)\s*=\s*FastAPI\(\)/;
// Match the patterns in the contentEntryfile
const flaskMatch = contentEntryfile.match(flaskPattern);
const fastAPIMatch = contentEntryfile.match(fastAPIPattern);
if (flaskMatch && flaskMatch[1]) {
return flaskMatch[1];
}
if (fastAPIMatch && fastAPIMatch[1]) {
return fastAPIMatch[1];
}
return "app";
}
const DEFAULT_COMPILER_OPTIONS = {
outDir: "dist",
rootDir: ".",
module: "commonjs",
};
const MODULE_FILE_EXTENSIONS = {
es6: ".js",
esnext: ".mjs",
es2015: ".js",
es2020: ".mjs",
es2022: ".mjs",
node16: ".mjs",
nodenext: ".mjs",
// CommonJS
commonjs: ".js",
amd: ".js",
umd: ".js",
system: ".js",
none: ".js",
};
/**
* Determines the output file path for a TypeScript project.
*
* @param entryFile - Source file path (e.g., "src/index.ts")
* @param tsconfigContent - tsconfig.json content as string or object
* @returns Compiled file path (e.g., "dist/index.js")
* @throws {Error} If entryFile is invalid
*/
export function getEntryFileTypescript(entryFile, tsconfigContent) {
if (!entryFile?.trim()) {
throw new Error("Entry file path is required");
}
const config = parseTypeScriptConfig(tsconfigContent);
const options = getCompilerOptions(config);
const normalizedPath = getNormalizedPath(entryFile, options.rootDir);
const extension = getOutputExtension(entryFile, options.module, options.moduleResolution, options.target);
const outputFileName = normalizedPath.replace(/\.ts$/, extension);
return path.join(options.outDir, outputFileName);
}
/**
* Parses the TypeScript configuration from a string or object.
*/
function parseTypeScriptConfig(config) {
if (typeof config === "string") {
try {
return JSON.parse(config);
}
catch (error) {
return { compilerOptions: DEFAULT_COMPILER_OPTIONS };
}
}
return config;
}
/**
* Extracts and normalizes compiler options with defaults.
*/
function getCompilerOptions(config) {
const userOptions = config.compilerOptions ?? {};
const inferredRootDir = config.include?.[0]?.replace(/[*\\/]+$/, ""); // "src*" -> "src"
return {
outDir: userOptions.outDir ?? DEFAULT_COMPILER_OPTIONS.outDir,
rootDir: userOptions.rootDir ?? inferredRootDir ?? DEFAULT_COMPILER_OPTIONS.rootDir,
module: userOptions.module ?? DEFAULT_COMPILER_OPTIONS.module,
moduleResolution: userOptions.moduleResolution,
target: userOptions.target,
};
}
/**
* Normalizes the input file path relative to rootDir.
*/
function getNormalizedPath(entryFile, rootDir) {
const relativePath = entryFile.startsWith(rootDir)
? entryFile.slice(rootDir.length).replace(/^[/\\]+/, "")
: path.relative(rootDir, entryFile);
return relativePath.replace(/\\/g, "/");
}
/**
* Determines the output file extension based on the module type and input file extension.
*/
function getOutputExtension(entryFile, moduleType, moduleResolution, target) {
// Handle explicit TypeScript module extensions first
if (entryFile.endsWith(".mts"))
return ".mjs";
if (entryFile.endsWith(".cts"))
return ".cjs";
if (entryFile.endsWith(".ts")) {
// Handle TypeScript files with explicit module resolution
const normalizedModule = moduleType?.toLowerCase() || "commonjs";
const normalizedResolution = moduleResolution?.toLowerCase();
// NodeNext and Node16 resolution takes precedence
if (normalizedResolution === "nodenext" || normalizedResolution === "node16") {
return normalizedModule === "commonjs" ? ".cjs" : ".mjs";
}
// Node resolution always outputs .js
if (normalizedResolution === "node") {
return ".js";
}
// Modern ECMAScript targets should use .mjs
if (target && target.toLowerCase().startsWith("es")) {
const version = parseInt(target.substring(2));
if (!isNaN(version) && version >= 2020) {
return ".mjs";
}
}
// Fallback to module-specific extensions
return (MODULE_FILE_EXTENSIONS[normalizedModule] ?? ".js");
}
// For non-TypeScript files, preserve the original extension
return path.extname(entryFile);
}