diff --git a/.changeset/wild-parrots-lie.md b/.changeset/wild-parrots-lie.md new file mode 100644 index 0000000000000..96c00bd044873 --- /dev/null +++ b/.changeset/wild-parrots-lie.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/framework": patch +--- + +chore: move build utilities to Compiler class diff --git a/packages/core/framework/package.json b/packages/core/framework/package.json index bed2a01df1d9e..fc42cc1835625 100644 --- a/packages/core/framework/package.json +++ b/packages/core/framework/package.json @@ -27,6 +27,7 @@ "./feature-flags": "./dist/feature-flags/index.js", "./utils": "./dist/utils/index.js", "./types": "./dist/types/index.js", + "./build-tools": "./dist/build-tools/index.js", "./orchestration": "./dist/orchestration/index.js", "./workflows-sdk": "./dist/workflows-sdk/index.js", "./workflows-sdk/composer": "./dist/workflows-sdk/composer.js", diff --git a/packages/core/framework/src/build-tools/compiler.ts b/packages/core/framework/src/build-tools/compiler.ts new file mode 100644 index 0000000000000..2eb383555d513 --- /dev/null +++ b/packages/core/framework/src/build-tools/compiler.ts @@ -0,0 +1,373 @@ +import path from "path" +import type tsStatic from "typescript" +import { getConfigFile } from "@medusajs/utils" +import { access, constants, copyFile, rm } from "fs/promises" +import type { AdminOptions, ConfigModule, Logger } from "@medusajs/types" + +/** + * The compiler exposes the opinionated APIs for compiling Medusa + * applications and plugins. You can perform the following + * actions. + * + * - loadTSConfigFile: Load and parse the TypeScript config file. All errors + * will be reported using the logger. + * + * - buildAppBackend: Compile the Medusa application backend source code to the + * ".medusa/server" directory. The admin source and integration-tests are + * skipped. + * + * - buildAppFrontend: Compile the admin extensions using the "@medusjs/admin-bundler" + * package. Admin can be compiled for self hosting (aka adminOnly), or can be compiled + * to be bundled with the backend output. + */ +export class Compiler { + #logger: Logger + #projectRoot: string + #adminSourceFolder: string + #adminOnlyDistFolder: string + #tsCompiler?: typeof tsStatic + + constructor(projectRoot: string, logger: Logger) { + this.#projectRoot = projectRoot + this.#logger = logger + this.#adminSourceFolder = path.join(this.#projectRoot, "src/admin") + this.#adminOnlyDistFolder = path.join(this.#projectRoot, ".medusa/admin") + } + + /** + * Util to track duration using hrtime + */ + #trackDuration() { + const startTime = process.hrtime() + return { + getSeconds() { + const duration = process.hrtime(startTime) + return (duration[0] + duration[1] / 1e9).toFixed(2) + }, + } + } + + /** + * Returns the dist folder from the tsconfig.outDir property + * or uses the ".medusa/server" folder + */ + #computeDist(tsConfig: { options: { outDir?: string } }): string { + const distFolder = tsConfig.options.outDir ?? ".medusa/server" + return path.isAbsolute(distFolder) + ? distFolder + : path.join(this.#projectRoot, distFolder) + } + + /** + * Imports and stores a reference to the TypeScript compiler. + * We dynamically import "typescript", since its is a dev + * only dependency + */ + async #loadTSCompiler() { + if (!this.#tsCompiler) { + this.#tsCompiler = await import("typescript") + } + return this.#tsCompiler + } + + /** + * Copies the file to the destination without throwing any + * errors if the source file is missing + */ + async #copy(source: string, destination: string) { + let sourceExists = false + try { + await access(source, constants.F_OK) + sourceExists = true + } catch (error) { + if (error.code !== "ENOENT") { + throw error + } + } + + if (sourceExists) { + await copyFile(path.join(source), path.join(destination)) + } + } + + /** + * Copies package manager files from the project root + * to the specified dist folder + */ + async #copyPkgManagerFiles(dist: string) { + /** + * Copying package manager files + */ + await this.#copy( + path.join(this.#projectRoot, "package.json"), + path.join(dist, "package.json") + ) + await this.#copy( + path.join(this.#projectRoot, "yarn.lock"), + path.join(dist, "yarn.lock") + ) + await this.#copy( + path.join(this.#projectRoot, "pnpm.lock"), + path.join(dist, "pnpm.lock") + ) + await this.#copy( + path.join(this.#projectRoot, "package-lock.json"), + path.join(dist, "package-lock.json") + ) + } + + /** + * Removes the directory and its children recursively and + * ignores any errors + */ + async #clean(path: string) { + await rm(path, { recursive: true }).catch(() => {}) + } + + /** + * Loads the medusa config file and prints the error to + * the console (in case of any errors). Otherwise, the + * file path and the parsed config is returned + */ + async #loadMedusaConfig() { + const { configModule, configFilePath, error } = + await getConfigFile(this.#projectRoot, "medusa-config") + if (error) { + this.#logger.error(`Failed to load medusa-config.(js|ts) file`) + this.#logger.error(error) + return + } + + return { configFilePath, configModule } + } + + /** + * Given a tsconfig file, this method will write the compiled + * output to the specified destination + */ + async #emitBuildOutput( + tsConfig: tsStatic.ParsedCommandLine, + chunksToIgnore: string[], + dist: string + ): Promise<{ + emitResult: tsStatic.EmitResult + diagnostics: tsStatic.Diagnostic[] + }> { + const ts = await this.#loadTSCompiler() + const filesToCompile = tsConfig.fileNames.filter((fileName) => { + return !chunksToIgnore.some((chunk) => fileName.includes(`${chunk}/`)) + }) + + /** + * Create emit program to compile and emit output + */ + const program = ts.createProgram(filesToCompile, { + ...tsConfig.options, + ...{ + outDir: dist, + inlineSourceMap: !tsConfig.options.sourceMap, + }, + }) + + const emitResult = program.emit() + const diagnostics = ts + .getPreEmitDiagnostics(program) + .concat(emitResult.diagnostics) + + /** + * Log errors (if any) + */ + if (diagnostics.length) { + console.error( + ts.formatDiagnosticsWithColorAndContext( + diagnostics, + ts.createCompilerHost({}) + ) + ) + } + + return { emitResult, diagnostics } + } + + /** + * Loads and parses the TypeScript config file. In case of an error, the errors + * will be logged using the logger and undefined it returned + */ + async loadTSConfigFile(): Promise { + const ts = await this.#loadTSCompiler() + let tsConfigErrors: tsStatic.Diagnostic[] = [] + + const tsConfig = ts.getParsedCommandLineOfConfigFile( + path.join(this.#projectRoot, "tsconfig.json"), + { + inlineSourceMap: true, + excludes: [], + }, + { + ...ts.sys, + useCaseSensitiveFileNames: true, + getCurrentDirectory: () => this.#projectRoot, + onUnRecoverableConfigFileDiagnostic: (error) => + (tsConfigErrors = [error]), + } + ) + + /** + * Push errors from the tsConfig parsed output to the + * tsConfigErrors array. + */ + if (tsConfig?.errors.length) { + tsConfigErrors.push(...tsConfig.errors) + } + + /** + * Display all config errors using the diagnostics reporter + */ + if (tsConfigErrors.length) { + const compilerHost = ts.createCompilerHost({}) + this.#logger.error( + ts.formatDiagnosticsWithColorAndContext(tsConfigErrors, compilerHost) + ) + return + } + + /** + * If there are no errors, the `tsConfig` object will always exist. + */ + return tsConfig! + } + + /** + * Builds the application backend source code using + * TypeScript's official compiler. Also performs + * type-checking + */ + async buildAppBackend( + tsConfig: tsStatic.ParsedCommandLine + ): Promise { + const tracker = this.#trackDuration() + const dist = this.#computeDist(tsConfig) + this.#logger.info("Compiling backend source...") + + /** + * Step 1: Cleanup existing build output + */ + this.#logger.info( + `Removing existing "${path.relative(this.#projectRoot, dist)}" folder` + ) + await this.#clean(dist) + + /** + * Step 2: Compile TypeScript source code + */ + const { emitResult, diagnostics } = await this.#emitBuildOutput( + tsConfig, + ["integration-tests", "test", "unit-tests", "src/admin"], + dist + ) + + /** + * Exit early if no output is written to the disk + */ + if (emitResult.emitSkipped) { + this.#logger.warn("Backend build completed without emitting any output") + return false + } + + /** + * Step 3: Copy package manager files to the output folder + */ + await this.#copyPkgManagerFiles(dist) + + /** + * Notify about the state of build + */ + if (diagnostics.length) { + this.#logger.warn( + `Backend build completed with errors (${tracker.getSeconds()}s)` + ) + } else { + this.#logger.info( + `Backend build completed successfully (${tracker.getSeconds()}s)` + ) + } + + return true + } + + /** + * Builds the frontend source code of a Medusa application + * using the "@medusajs/admin-bundler" package. + */ + async buildAppFrontend( + adminOnly: boolean, + tsConfig: tsStatic.ParsedCommandLine, + adminBundler: { + build: ( + options: AdminOptions & { + sources: string[] + outDir: string + } + ) => Promise + } + ): Promise { + const tracker = this.#trackDuration() + + /** + * Step 1: Load the medusa config file to read + * admin options + */ + const configFile = await this.#loadMedusaConfig() + if (!configFile) { + return false + } + + /** + * Return early when admin is disabled and we are trying to + * create a bundled build for the admin. + */ + if (configFile.configModule.admin.disable && !adminOnly) { + this.#logger.info( + "Skipping admin build, since its disabled inside the medusa-config file" + ) + return false + } + + /** + * Warn when we are creating an admin only build, but forgot to disable + * the admin inside the config file + */ + if (!configFile.configModule.admin.disable && adminOnly) { + this.#logger.warn( + `You are building using the flag --admin-only but the admin is enabled in your medusa-config, If you intend to host the dashboard separately you should disable the admin in your medusa config` + ) + } + + try { + this.#logger.info("Compiling frontend source...") + await adminBundler.build({ + disable: false, + sources: [this.#adminSourceFolder], + ...configFile.configModule.admin, + outDir: adminOnly + ? this.#adminOnlyDistFolder + : path.join(this.#computeDist(tsConfig), "./public/admin"), + }) + + this.#logger.info( + `Frontend build completed successfully (${tracker.getSeconds()}s)` + ) + return true + } catch (error) { + this.#logger.error("Unable to compile frontend source") + this.#logger.error(error) + return false + } + } + + /** + * @todo. To be implemented + */ + buildPluginBackend() {} + developPluginBacked() {} +} diff --git a/packages/core/framework/src/build-tools/index.ts b/packages/core/framework/src/build-tools/index.ts new file mode 100644 index 0000000000000..1dae1fbc9c809 --- /dev/null +++ b/packages/core/framework/src/build-tools/index.ts @@ -0,0 +1 @@ +export * from "./compiler" diff --git a/packages/medusa/src/commands/build.ts b/packages/medusa/src/commands/build.ts index 7c95d962f8bb9..e321e7bf827fd 100644 --- a/packages/medusa/src/commands/build.ts +++ b/packages/medusa/src/commands/build.ts @@ -1,273 +1,7 @@ -import path from "path" -import { access, constants, copyFile, rm } from "node:fs/promises" -import type tsStatic from "typescript" import { logger } from "@medusajs/framework/logger" -import { ConfigModule } from "@medusajs/framework/types" -import { getConfigFile } from "@medusajs/framework/utils" -import { - ADMIN_ONLY_OUTPUT_DIR, - ADMIN_RELATIVE_OUTPUT_DIR, - ADMIN_SOURCE_DIR, -} from "../utils" +import { Compiler } from "@medusajs/framework/build-tools" -const INTEGRATION_TESTS_FOLDER = "integration-tests" - -function computeDist( - projectRoot: string, - tsConfig: { options: { outDir?: string } } -): string { - const distFolder = tsConfig.options.outDir ?? ".medusa/server" - return path.isAbsolute(distFolder) - ? distFolder - : path.join(projectRoot, distFolder) -} - -async function loadTsConfig(projectRoot: string) { - const ts = await import("typescript") - const tsConfig = parseTSConfig(projectRoot, ts) - if (!tsConfig) { - logger.error("Unable to compile backend source") - return false - } - - return tsConfig! -} - -/** - * Copies the file to the destination without throwing any - * errors if the source file is missing - */ -async function copy(source: string, destination: string) { - let sourceExists = false - try { - await access(source, constants.F_OK) - sourceExists = true - } catch (error) { - if (error.code !== "ENOENT") { - throw error - } - } - - if (sourceExists) { - await copyFile(path.join(source), path.join(destination)) - } -} - -/** - * Removes the directory and its children recursively and - * ignores any errors - */ -async function clean(path: string) { - await rm(path, { recursive: true }).catch(() => {}) -} - -/** - * Loads the medusa config file or exits with an error - */ -async function loadMedusaConfig(directory: string) { - /** - * Parsing the medusa config file to ensure it is error - * free - */ - const { configModule, configFilePath, error } = - await getConfigFile(directory, "medusa-config") - if (error) { - console.error(`Failed to load medusa-config.js`) - console.error(error) - return - } - - return { configFilePath, configModule } -} - -/** - * Parses the tsconfig file or exits with an error in case - * the file is invalid - */ -function parseTSConfig(projectRoot: string, ts: typeof tsStatic) { - let tsConfigErrors: null | tsStatic.Diagnostic = null - - const tsConfig = ts.getParsedCommandLineOfConfigFile( - path.join(projectRoot, "tsconfig.json"), - { - inlineSourceMap: true, - excludes: [], - }, - { - ...ts.sys, - useCaseSensitiveFileNames: true, - getCurrentDirectory: () => projectRoot, - onUnRecoverableConfigFileDiagnostic: (error) => (tsConfigErrors = error), - } - ) - - if (tsConfigErrors) { - const compilerHost = ts.createCompilerHost({}) - console.error( - ts.formatDiagnosticsWithColorAndContext([tsConfigErrors], compilerHost) - ) - return - } - - if (tsConfig!.errors.length) { - const compilerHost = ts.createCompilerHost({}) - console.error( - ts.formatDiagnosticsWithColorAndContext(tsConfig!.errors, compilerHost) - ) - return - } - - return tsConfig! -} - -/** - * Builds the backend project using TSC - */ -async function buildBackend( - projectRoot: string, - tsConfig: tsStatic.ParsedCommandLine -): Promise { - const startTime = process.hrtime() - logger.info("Compiling backend source...") - - const dist = computeDist(projectRoot, tsConfig) - - logger.info(`Removing existing "${path.relative(projectRoot, dist)}" folder`) - await clean(dist) - - /** - * Ignoring admin and integration tests from the compiled - * files - */ - const filesToCompile = tsConfig.fileNames.filter((fileName) => { - return ( - !fileName.includes(`${ADMIN_SOURCE_DIR}/`) && - !fileName.includes(`${INTEGRATION_TESTS_FOLDER}/`) - ) - }) - - const ts = await import("typescript") - const program = ts.createProgram(filesToCompile, { - ...tsConfig.options, - ...{ - outDir: dist, - - /** - * Disable inline source maps when the user has enabled - * source maps within the config file - */ - inlineSourceMap: !tsConfig.options.sourceMap, - }, - }) - - const emitResult = program.emit() - const diagnostics = ts - .getPreEmitDiagnostics(program) - .concat(emitResult.diagnostics) - - /** - * Log errors (if any) - */ - if (diagnostics.length) { - console.error( - ts.formatDiagnosticsWithColorAndContext( - diagnostics, - ts.createCompilerHost({}) - ) - ) - } - - /** - * Exit early if no output is written to the disk - */ - if (emitResult.emitSkipped) { - logger.warn("Backend build completed without emitting any output") - return false - } - - /** - * Copying package manager files - */ - await copy( - path.join(projectRoot, "package.json"), - path.join(dist, "package.json") - ) - await copy(path.join(projectRoot, "yarn.lock"), path.join(dist, "yarn.lock")) - await copy(path.join(projectRoot, "pnpm.lock"), path.join(dist, "pnpm.lock")) - await copy( - path.join(projectRoot, "package-lock.json"), - path.join(dist, "package-lock.json") - ) - - const duration = process.hrtime(startTime) - const seconds = (duration[0] + duration[1] / 1e9).toFixed(2) - - if (diagnostics.length) { - logger.warn(`Backend build completed with errors (${seconds}s)`) - } else { - logger.info(`Backend build completed successfully (${seconds}s)`) - } - - return true -} - -/** - * Builds the frontend project using the "@medusajs/admin-bundler" - */ -async function buildFrontend( - projectRoot: string, - adminOnly: boolean, - tsConfig: tsStatic.ParsedCommandLine -): Promise { - const startTime = process.hrtime() - const configFile = await loadMedusaConfig(projectRoot) - if (!configFile) { - return false - } - - const dist = computeDist(projectRoot, tsConfig) - - const adminOutputPath = adminOnly - ? path.join(projectRoot, ADMIN_ONLY_OUTPUT_DIR) - : path.join(dist, ADMIN_RELATIVE_OUTPUT_DIR) - - const adminSource = path.join(projectRoot, ADMIN_SOURCE_DIR) - const adminOptions = { - disable: false, - sources: [adminSource], - ...configFile.configModule.admin, - outDir: adminOutputPath, - } - - if (adminOptions.disable && !adminOnly) { - return false - } - - if (!adminOptions.disable && adminOnly) { - logger.warn( - `You are building using the flag --admin-only but the admin is enabled in your medusa-config, If you intend to host the dashboard separately you should disable the admin in your medusa config` - ) - } - - try { - logger.info("Compiling frontend source...") - const { build: buildProductionBuild } = await import( - "@medusajs/admin-bundler" - ) - await buildProductionBuild(adminOptions) - const duration = process.hrtime(startTime) - const seconds = (duration[0] + duration[1] / 1e9).toFixed(2) - - logger.info(`Frontend build completed successfully (${seconds}s)`) - return true - } catch (error) { - logger.error("Unable to compile frontend source") - console.error(error) - return false - } -} - -export default async function ({ +export default async function build({ directory, adminOnly, }: { @@ -275,20 +9,21 @@ export default async function ({ adminOnly: boolean }): Promise { logger.info("Starting build...") + const compiler = new Compiler(directory, logger) - const tsConfig = await loadTsConfig(directory) + const tsConfig = await compiler.loadTSConfigFile() if (!tsConfig) { + logger.error("Unable to compile application") return false } const promises: Promise[] = [] - if (!adminOnly) { - promises.push(buildBackend(directory, tsConfig)) + promises.push(compiler.buildAppBackend(tsConfig)) } - promises.push(buildFrontend(directory, adminOnly, tsConfig)) - + const bundler = await import("@medusajs/admin-bundler") + promises.push(compiler.buildAppFrontend(adminOnly, tsConfig, bundler)) await Promise.all(promises) return true }