breaking: Standalone builds (#9496)

Fixes: FRMW-2742

In this PR, we fix the build output of the backend source code, which eliminates a lot of magic between the development and production environments.

Right now, we only compile the source files from the `src` directory and write them within the `dist` directory.

**Here's how the `src` directory with a custom module looks like**

```
src
├── modules
│   └── hello
│       ├── index.ts
```

**Here's the build output**

```
dist
├── modules
│   └── hello
│       ├── index.js
```

Let's imagine a file at the root of your project (maybe the `medusa-config.js` file) that wants to import the `modules/hello/index` file. How can we ensure that the import will work in both the development and production environments?

If we write the import targeting the `src` directory, it will break in production because it should target the `dist` directory.

## Solution
The solution is to compile everything within the project and mimic the file structure in the build output, not just the `src` directory.

**Here's how the fixed output should look like**

```
dist
├── src
│  ├── modules
│  │   └── hello
│  │       ├── index.js
├── medusa-config.js
├── yarn.lock
├── package.json
```

If you notice carefully, we also have `medusa-config.js`, `yarn.lock`, and `package.json` within the `dist` directory. We do so to create a standalone built application, something you can copy/paste to your server and run without relying on the original source code.

- This results in small containers since you are not copying unnecessary files.
- Clear distinction between the development and the production code. If you want to run the production server, then `cd` into the `dist` directory and run it from there.

## Changes in the PR

- Breaking: Remove the `dist` and `build` folders. Instead, write them production artefacts within the `.medusa` directory as `.medusa/admin` and `.medusa/server`.
- Breaking: Change the output of the `.medusa/server` folder to mimic the root project structure.
- Refactor: Remove `Symbol.for("ts-node.register.instance")]` check to find from where to load the source code.
- Refactor: Use `tsc` for creating the production build. This ensures we respect `tsconfig` settings when creating the build and also perform type-checking.

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Harminder Virk
2024-10-09 15:59:08 +00:00
committed by GitHub
co-authored by Adrien de Peretti
parent 5c9457bb4f
commit 1560d7ed5f
27 changed files with 292 additions and 220 deletions
+204 -137
View File
@@ -1,170 +1,237 @@
import path from "path"
import { rm, copyFile, access, constants } 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 { transformFile } from "@swc/core"
import { existsSync } from "node:fs"
import { copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises"
import path from "path"
type BuildArgs = {
directory: string
const ADMIN_FOLDER = "src/admin"
const INTEGRATION_TESTS_FOLDER = "integration-tests"
/**
* 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))
}
}
type FileConfig = {
inputDir: string
outputDir: string
targetExtension?: string
}
const INPUT_DIR = "./src"
const OUTPUT_DIR = "./dist"
const COMPILE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]
const IGNORE_EXTENSIONS = [".md"]
/**
* Removes the directory and its children recursively and
* ignores any errors
*/
async function clean(path: string) {
await rm(path, { recursive: true }).catch(() => {})
}
async function findFiles(dir: string): Promise<string[]> {
try {
const files = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(
files.map(async (file) => {
const res = path.join(dir, file.name)
return file.isDirectory() ? findFiles(res) : res
})
)
return paths.flat()
} catch (e) {
console.error(`Failed to read directory ${dir}`)
console.error(e)
throw e
}
}
const getOutputPath = (file: string, config: FileConfig) => {
const { inputDir, outputDir, targetExtension } = config
const inputDirName = path.basename(inputDir)
const outputDirName = path.basename(outputDir)
const relativePath = file.replace(inputDirName, outputDirName)
let outputPath = relativePath
if (targetExtension) {
const currentExtension = path.extname(outputPath)
outputPath = outputPath.replace(currentExtension, targetExtension)
}
return outputPath.replaceAll(path.sep, "/")
}
const writeToOut = async (
file: string,
content: string,
config: FileConfig
) => {
const outputPath = getOutputPath(file, config)
await mkdir(outputPath.replace(/\/[^/]+$/, ""), { recursive: true })
await writeFile(outputPath, content)
}
async function copyToOut(file: string, config: FileConfig) {
const outputPath = getOutputPath(file, config)
await mkdir(outputPath.replace(/\/[^/]+$/, ""), { recursive: true })
await copyFile(file, outputPath)
}
const medusaTransform = async (file: string) => {
if (COMPILE_EXTENSIONS.some((ext) => file.endsWith(ext))) {
const outputPath = getOutputPath(file, {
inputDir: INPUT_DIR,
outputDir: OUTPUT_DIR,
})
const output = await transformFile(file, {
sourceFileName: path.relative(path.dirname(outputPath), file),
sourceMaps: "inline",
module: {
type: "commonjs",
},
jsc: {
parser: {
syntax: "typescript",
decorators: true,
},
transform: {
decoratorMetadata: true,
},
target: "es2021",
externalHelpers: true,
},
})
await writeToOut(file, output.code, {
inputDir: INPUT_DIR,
outputDir: OUTPUT_DIR,
targetExtension: ".js",
})
} else if (!IGNORE_EXTENSIONS.some((ext) => file.endsWith(ext))) {
// Copy non-ts files
await copyToOut(file, { inputDir: INPUT_DIR, outputDir: OUTPUT_DIR })
}
}
export default async function ({ directory }: BuildArgs) {
const started = Date.now()
const { configModule, error } = getConfigFile<ConfigModule>(
/**
* Loads the medusa config file or exits with an error
*/
function loadMedusaConfig(directory: string) {
/**
* Parsing the medusa config file to ensure it is error
* free
*/
const { configModule, configFilePath, error } = getConfigFile<ConfigModule>(
directory,
"medusa-config"
)
if (error) {
console.error(`Failed to load medusa-config.js`)
console.error(error)
process.exit(1)
return
}
const input = path.join(directory, INPUT_DIR)
const dist = path.join(directory, OUTPUT_DIR)
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): Promise<boolean> {
const startTime = process.hrtime()
logger.info("Compiling backend source...")
const ts = await import("typescript")
const tsConfig = parseTSConfig(projectRoot, ts)
if (!tsConfig) {
logger.error("Unable to compile backend source")
return false
}
const distFolder = tsConfig.options.outDir ?? ".medusa/server"
const dist = path.isAbsolute(distFolder)
? distFolder
: path.join(projectRoot, distFolder)
logger.info(`Removing existing "${path.relative(projectRoot, dist)}" folder`)
await clean(dist)
const files = await findFiles(input)
/**
* Ignoring admin and integration tests from the compiled
* files
*/
const filesToCompile = tsConfig.fileNames.filter((fileName) => {
return (
!fileName.includes(`${ADMIN_FOLDER}/`) &&
!fileName.includes(`${INTEGRATION_TESTS_FOLDER}/`)
)
})
await Promise.all(files.map(medusaTransform))
const program = ts.createProgram(filesToCompile, {
...tsConfig.options,
...{
/**
* Disable inline source maps when the user has enabled
* source maps within the config file
*/
inlineSourceMap: !tsConfig.options.sourceMap,
},
})
const sources: string[] = []
const emitResult = program.emit()
const diagnostics = ts
.getPreEmitDiagnostics(program)
.concat(emitResult.diagnostics)
const projectSource = path.join(directory, "src", "admin")
if (existsSync(projectSource)) {
sources.push(projectSource)
/**
* 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): Promise<boolean> {
const startTime = process.hrtime()
const configFile = loadMedusaConfig(projectRoot)
if (!configFile) {
return false
}
const adminSource = path.join(projectRoot, ADMIN_FOLDER)
const adminOptions = {
disable: false,
path: "/app" as const,
outDir: "./build",
sources,
...configModule.admin,
sources: [adminSource],
...configFile.configModule.admin,
}
if (!adminOptions.disable) {
try {
const { build: buildProductionBuild } = await import(
"@medusajs/admin-bundler"
)
await buildProductionBuild(adminOptions)
} catch (error) {
console.error("Failed to build admin")
console.error(error)
}
if (adminOptions.disable) {
return false
}
const time = Date.now() - started
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)
console.log(`Build completed in ${time}ms`)
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 ({ directory }: { directory: string }) {
logger.info("Starting build...")
await Promise.all([buildBackend(directory), buildFrontend(directory)])
}
+5 -3
View File
@@ -17,7 +17,7 @@ import { MedusaModule } from "@medusajs/framework/modules-sdk"
const EVERY_SIXTH_HOUR = "0 */6 * * *"
const CRON_SCHEDULE = EVERY_SIXTH_HOUR
const INSTRUMENTATION_FILE = "instrumentation.js"
const INSTRUMENTATION_FILE = "instrumentation"
/**
* Imports the "instrumentation.js" file from the root of the
@@ -27,7 +27,9 @@ const INSTRUMENTATION_FILE = "instrumentation.js"
*/
export async function registerInstrumentation(directory: string) {
const fileSystem = new FileSystem(directory)
const exists = await fileSystem.exists(INSTRUMENTATION_FILE)
const exists =
(await fileSystem.exists(`${INSTRUMENTATION_FILE}.ts`)) ||
(await fileSystem.exists(`${INSTRUMENTATION_FILE}.js`))
if (!exists) {
return
}
@@ -83,7 +85,7 @@ async function start({ port, directory, types }) {
})
if (gqlSchema && types) {
const outputDirGeneratedTypes = path.join(directory, ".medusa")
const outputDirGeneratedTypes = path.join(directory, ".medusa/types")
await gqlSchemaToTypes({
outputDir: outputDirGeneratedTypes,
filename: "remote-query-entry-points",
-2
View File
@@ -37,8 +37,6 @@ export default async function adminLoader({
const adminOptions: IntializedOptions = {
disable: false,
path: "/app",
outDir: "./build",
sources,
...admin,
}
@@ -14,14 +14,7 @@ function createFileContentHash(path, files): string {
}
function getExtensionDirectoryPath() {
/**
* Grab directory for loading resources inside a starter kit from
* the medusa-config file.
*
* When using ts-node we will read resources from "src" directory
* otherwise from "dist" directory.
*/
return process[Symbol.for("ts-node.register.instance")] ? "src" : "dist"
return "src"
}
/**
+1 -1
View File
@@ -118,7 +118,7 @@ async function loadEntrypoints(
export async function initializeContainer(
rootDirectory: string
): Promise<MedusaContainer> {
configLoader(rootDirectory, "medusa-config.js")
configLoader(rootDirectory, "medusa-config")
await featureFlagsLoader(join(__dirname, "feature-flags"))
container.register({