chore: Backend HMR (expriemental) (#14074)

**What**

 This PR introduces experimental Hot Module Replacement (HMR) for the Medusa backend, enabling developers to see code changes reflected immediately without restarting the server. This significantly improves the development experience by reducing iteration time.

### Key Features

  - Hot reload support for:
    - API Routes
    - Workflows & Steps
    - Scheduled Jobs
    - Event Subscribers
    - Modules
  - IPC-based architecture: The dev server runs in a child process, communicating with the parent watcher via IPC. When HMR fails, the child process is killed and restarted, ensuring
  clean resource cleanup.
  - Recovery mechanism: Automatically recovers from broken module states without manual intervention.
  - Graceful fallback: When HMR cannot handle a change (e.g., medusa-config.ts, .env), the server restarts completely.


### Architecture
```mermaid
  flowchart TB
      subgraph Parent["develop.ts (File Watcher)"]
          W[Watch Files]
      end

      subgraph Child["start.ts (HTTP Server)"]
          R[reloadResources]
          R --> MR[ModuleReloader]
          R --> WR[WorkflowReloader]
          R --> RR[RouteReloader]
          R --> SR[SubscriberReloader]
          R --> JR[JobReloader]
      end

      W -->|"hmr-reload"| R
      R -->|"hmr-result"| W
```

### How to enable it

Backend HMR is behind a feature flag. Enable it by setting:

```ts
  // medusa-config.ts
  module.exports = defineConfig({
    featureFlags: {
      backend_hmr: true
    }
  })
```

or

```bash
export MEDUSA_FF_BACKEND_HMR=true
```

or

```
// .env
MEDUSA_FF_BACKEND_HMR=true
```

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-12-08 08:48:36 +00:00
1 parent 4de555b546
commit fe49b567d6
38 files changed
+2222 -61

No files matched your search

+1 -1
View File
@@ -106,7 +106,7 @@
"@medusajs/workflow-engine-redis": "2.12.1",
"boxen": "^5.0.1",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
"chokidar": "^4.0.3",
"compression": "^1.8.0",
"express": "^4.21.0",
"fs-exists-cached": "^1.0.0",
+123 -25
View File
@@ -1,11 +1,15 @@
import { MEDUSA_CLI_PATH } from "@medusajs/framework"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import {
ContainerRegistrationKeys,
FeatureFlag,
} from "@medusajs/framework/utils"
import { Store } from "@medusajs/telemetry"
import boxen from "boxen"
import { ChildProcess, execSync, fork } from "child_process"
import chokidar, { FSWatcher } from "chokidar"
import { EOL } from "os"
import path from "path"
import BackendHmrFeatureFlag from "../feature-flags/backend-hmr"
import { initializeContainer } from "../loaders"
const defaultConfig = {
@@ -18,6 +22,23 @@ export default async function ({ types, directory }) {
const container = await initializeContainer(directory)
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const isBackendHmrEnabled = FeatureFlag.isFeatureEnabled(
BackendHmrFeatureFlag.key
)
const reloadActionVerb = isBackendHmrEnabled ? "reloading" : "restarting"
const logSource = isBackendHmrEnabled ? "[HMR]" : "[Watcher]"
if (isBackendHmrEnabled) {
logger.info(
`${logSource} Using backend HMR dev server (reload on file change)`
)
} else {
logger.info(
`${logSource} Using standard dev server (restart on file change)`
)
}
const args = process.argv
const argv =
@@ -53,19 +74,66 @@ export default async function ({ types, directory }) {
* restart the dev server instead of asking the user to re-run
* the command.
*/
start() {
this.childProcess = fork(cliPath, ["start", ...args], {
async start() {
const forkOptions: any = {
cwd: directory,
env: {
...process.env,
NODE_ENV: "development",
...(isBackendHmrEnabled && { MEDUSA_HMR_ENABLED: "true" }),
},
execArgv: argv,
})
}
// Enable IPC for HMR mode to communicate reload requests
if (isBackendHmrEnabled) {
forkOptions.stdio = ["inherit", "inherit", "inherit", "ipc"]
}
this.childProcess = fork(cliPath, ["start", ...args], forkOptions)
this.childProcess.on("error", (error) => {
// @ts-ignore
logger.error("Dev server failed to start", error)
logger.info("The server will restart automatically after your changes")
logger.error(`${logSource} Dev server failed to start`, error)
logger.info(
`${logSource} The server will restart automatically after your changes`
)
})
},
/**
* Sends an HMR reload request to the child process and waits for result.
* Returns true if reload succeeded, false if it failed.
*/
async sendHmrReload(
action: "add" | "change" | "unlink",
file: string
): Promise<boolean> {
return new Promise((resolve) => {
if (!this.childProcess) {
resolve(false)
return
}
const timeout = setTimeout(() => {
resolve(false)
}, 30000) // 30s timeout
const messageHandler = (msg: any) => {
if (msg?.type === "hmr-result") {
clearTimeout(timeout)
this.childProcess?.off("message", messageHandler)
resolve(msg.success === true)
}
}
this.childProcess.on("message", messageHandler)
this.childProcess.send({
type: "hmr-reload",
action,
file: path.resolve(directory, file),
rootDirectory: directory,
})
})
},
@@ -73,7 +141,18 @@ export default async function ({ types, directory }) {
* Restarts the development server by cleaning up the existing
* child process and forking a new one
*/
restart() {
async restart(action: "add" | "change" | "unlink", file: string) {
if (isBackendHmrEnabled && this.childProcess) {
const success = await this.sendHmrReload(action, file)
if (success) {
return
}
// HMR reload failed, kill the process and restart
logger.info(`${logSource} HMR reload failed, restarting server...`)
}
if (this.childProcess) {
this.childProcess.removeAllListeners()
if (process.platform === "win32") {
@@ -82,7 +161,7 @@ export default async function ({ types, directory }) {
this.childProcess.kill("SIGINT")
}
}
this.start()
await this.start()
},
/**
@@ -94,9 +173,9 @@ export default async function ({ types, directory }) {
* - src/admin/**
*/
watch() {
this.watcher = chokidar.watch(["."], {
this.watcher = chokidar.watch(".", {
ignoreInitial: true,
cwd: process.cwd(),
cwd: directory,
ignored: [
/(^|[\\/\\])\../,
"node_modules",
@@ -108,27 +187,46 @@ export default async function ({ types, directory }) {
],
})
this.watcher.on("add", (file) => {
async function handleFileChange(
this: typeof devServer,
action: "add" | "change" | "unlink",
file: string
) {
const actionVerb =
action === "add"
? "created"
: action === "change"
? "modified"
: "removed"
const now = performance.now()
logger.info(
`${path.relative(directory, file)} created: Restarting dev server`
`${logSource} ${actionVerb} ${path.relative(
directory,
file
)} ${actionVerb}: ${reloadActionVerb} dev server`
)
this.restart()
await this.restart(action, file)
const duration = performance.now() - now
logger.info(`${logSource} Reloaded in ${duration.toFixed(2)}ms`)
}
this.watcher.on("add", async (file) => {
handleFileChange.call(this, "add", file)
})
this.watcher.on("change", (file) => {
logger.info(
`${path.relative(directory, file)} modified: Restarting dev server`
)
this.restart()
this.watcher.on("change", async (file) => {
handleFileChange.call(this, "change", file)
})
this.watcher.on("unlink", (file) => {
logger.info(
`${path.relative(directory, file)} removed: Restarting dev server`
)
this.restart()
this.watcher.on("unlink", async (file) => {
handleFileChange.call(this, "unlink", file)
})
this.watcher.on("ready", function () {
logger.info(`Watching filesystem to reload dev server on file change`)
logger.info(
`${logSource} Watching filesystem to reload dev server on file change`
)
})
},
}
@@ -152,6 +250,6 @@ export default async function ({ types, directory }) {
process.exit(0)
})
devServer.start()
await devServer.start()
devServer.watch()
}
+43 -5
View File
@@ -18,9 +18,11 @@ import {
} from "@medusajs/framework/utils"
import { MedusaModule } from "@medusajs/framework/modules-sdk"
import { MedusaContainer } from "@medusajs/framework/types"
import { Logger, MedusaContainer } from "@medusajs/framework/types"
import { parse } from "url"
import loaders, { initializeContainer } from "../loaders"
import { reloadResources } from "./utils/dev-server"
import { HMRReloadError } from "./utils/dev-server/errors"
const EVERY_SIXTH_HOUR = "0 */6 * * *"
const CRON_SCHEDULE = EVERY_SIXTH_HOUR
@@ -163,6 +165,37 @@ function findExpressRoutePath({
return undefined
}
function handleHMRReload(logger: Logger) {
// Set up HMR reload handler if running in HMR mode
if (process.env.MEDUSA_HMR_ENABLED === "true" && process.send) {
;(global as any).__MEDUSA_HMR_ROUTE_REGISTRY__ = true
process.on("message", async (msg: any) => {
if (msg?.type === "hmr-reload") {
const { action, file, rootDirectory } = msg
const success = await reloadResources({
logSource: "[HMR]",
action,
absoluteFilePath: file,
logger,
rootDirectory,
})
.then(() => true)
.catch((error) => {
if (HMRReloadError.isHMRReloadError(error)) {
return false
}
logger.error("[HMR] Reload failed with unexpected error", error)
return false
})
process.send!({ type: "hmr-result", success })
}
})
}
}
async function start(args: {
directory: string
host?: string
@@ -171,7 +204,10 @@ async function start(args: {
cluster?: string
workers?: string
servers?: string
}) {
}): Promise<{
server: GracefulShutdownServer
gracefulShutDown: () => void
} | void> {
const {
port = 9000,
host,
@@ -296,7 +332,9 @@ async function start(args: {
track("PING")
})
return { server }
handleHMRReload(logger)
return { server, gracefulShutDown }
} catch (err) {
logger.error("Error starting server", err)
process.exit(1)
@@ -357,14 +395,14 @@ async function start(args: {
process.env.PLUGIN_ADMIN_UI_SKIP_CACHE = "true"
}
await internalStart(!!types && msg.index === 0)
return await internalStart(!!types && msg.index === 0)
})
}
} else {
/**
* Not in cluster mode
*/
await internalStart(!!types)
return await internalStart(!!types)
}
}
@@ -0,0 +1,10 @@
export class HMRReloadError extends Error {
static isHMRReloadError(error: Error): error is HMRReloadError {
return error instanceof HMRReloadError || error.name === "HMRReloadError"
}
constructor(message: string) {
super(message)
this.name = "HMRReloadError"
}
}
@@ -0,0 +1,137 @@
import { container } from "@medusajs/framework"
import { logger } from "@medusajs/framework/logger"
import { ModuleCacheManager } from "./module-cache-manager"
import { RecoveryService } from "./recovery-service"
import { RouteReloader } from "./reloaders/routes"
import { SubscriberReloader } from "./reloaders/subscribers"
import { WorkflowReloader } from "./reloaders/workflows"
import { ResourceRegistry } from "./resource-registry"
import { DevServerGlobals, ReloadParams } from "./types"
import { JobReloader } from "./reloaders/jobs"
import { ModuleReloader } from "./reloaders/modules"
import { HMRReloadError } from "./errors"
let sharedCacheManager!: ModuleCacheManager
const sharedRegistry = new ResourceRegistry()
const reloaders = {} as {
routesReloader: RouteReloader
subscribersReloader?: SubscriberReloader
workflowsReloader: WorkflowReloader
jobsReloader?: JobReloader
modulesReloader?: ModuleReloader
}
function initializeReloaders(logSource: string, rootDirectory: string) {
sharedCacheManager ??= new ModuleCacheManager(logSource)
const globals = global as unknown as DevServerGlobals
if (!reloaders.routesReloader) {
const routeReloader = new RouteReloader(
globals.__MEDUSA_HMR_API_LOADER__,
sharedCacheManager,
logSource,
logger
)
reloaders.routesReloader = routeReloader
}
if (!reloaders.subscribersReloader) {
const subscriberReloader = new SubscriberReloader(
container,
sharedCacheManager,
sharedRegistry,
logSource,
logger
)
reloaders.subscribersReloader = subscriberReloader
}
if (!reloaders.workflowsReloader) {
const workflowReloader = new WorkflowReloader(
globals.WorkflowManager,
sharedCacheManager,
sharedRegistry,
reloadResources,
logSource,
logger,
rootDirectory
)
reloaders.workflowsReloader = workflowReloader
}
if (!reloaders.jobsReloader) {
const jobReloader = new JobReloader(
globals.WorkflowManager,
sharedCacheManager,
container,
sharedRegistry,
logSource,
logger
)
reloaders.jobsReloader = jobReloader
}
if (!reloaders.modulesReloader) {
const moduleReloader = new ModuleReloader(
sharedCacheManager,
rootDirectory,
logSource,
logger
)
reloaders.modulesReloader = moduleReloader
}
}
const unmanagedFiles = ["medusa-config", ".env"]
/**
* Main entry point for reloading resources (routes, subscribers, workflows, and modules)
* Orchestrates the reload process and handles recovery of broken modules
*/
export async function reloadResources({
logSource,
action,
absoluteFilePath,
keepCache,
logger,
skipRecovery = false,
rootDirectory,
}: ReloadParams): Promise<void> {
if (unmanagedFiles.some((file) => absoluteFilePath.includes(file))) {
throw new HMRReloadError(
`File ${absoluteFilePath} is not managed by the dev server HMR. Server restart may be required.`
)
}
initializeReloaders(logSource, rootDirectory)
// Reload modules first as other resources might depend on them
await reloaders.modulesReloader?.reload?.(action, absoluteFilePath)
// Reload in dependency order: workflows → routes → subscribers → jobs
// Jobs depend on workflows, so workflows must be reloaded first
await reloaders.workflowsReloader.reload(
action,
absoluteFilePath,
keepCache,
skipRecovery
)
await reloaders.routesReloader.reload(action, absoluteFilePath)
await reloaders.subscribersReloader?.reload?.(action, absoluteFilePath)
await reloaders.jobsReloader?.reload?.(action, absoluteFilePath)
// Attempt recovery of broken modules (unless we're already in recovery mode)
if (!skipRecovery) {
const recoveryService = new RecoveryService(
sharedCacheManager,
reloadResources,
logSource,
logger,
rootDirectory
)
await recoveryService.recoverBrokenModules()
}
}
@@ -0,0 +1,204 @@
import { Logger } from "@medusajs/framework/types"
import path from "path"
import { CONFIG } from "./types"
/**
* Manages Node.js require cache operations and tracks broken modules
*/
export class ModuleCacheManager {
constructor(private readonly logSource: string) {}
private brokenModules: Set<string> = new Set()
/**
* Check if a module path should be excluded from cache operations
*/
private shouldExcludePath(modulePath: string): boolean {
return CONFIG.EXCLUDED_PATH_PATTERNS.some((pattern) =>
modulePath.includes(pattern)
)
}
/**
* Clear cache for descendant modules recursively
*/
private async clearDescendantModules(
modulePath: string,
visitedModules: Set<string>,
logger?: Logger,
onClear?: (path: string) => Promise<void>
): Promise<void> {
if (this.shouldExcludePath(modulePath) || visitedModules.has(modulePath)) {
return
}
visitedModules.add(modulePath)
const moduleEntry = require.cache[modulePath]
if (!moduleEntry) {
return
}
// Recursively clear children first
if (moduleEntry.children) {
for (const child of moduleEntry.children) {
await this.clearDescendantModules(
child.id,
visitedModules,
logger,
onClear
)
}
}
delete require.cache[modulePath]
if (onClear) {
await onClear(modulePath)
}
this.logCacheClear(modulePath, logger, "Cleared cache")
}
/**
* Clear cache for parent modules recursively
*/
private async clearParentModules(
targetPath: string,
visitedModules: Set<string>,
logger?: Logger,
onClear?: (path: string) => Promise<void>,
trackBroken: boolean = true
): Promise<void> {
const parentsToCheck = this.findParentModules(targetPath)
for (const modulePath of parentsToCheck) {
if (visitedModules.has(modulePath)) {
continue
}
visitedModules.add(modulePath)
// Recursively clear parents first
await this.clearParentModules(
modulePath,
visitedModules,
logger,
onClear,
trackBroken
)
// Track as potentially broken before deletion
if (trackBroken) {
this.brokenModules.add(modulePath)
}
delete require.cache[modulePath]
if (onClear) {
await onClear(modulePath)
}
this.logCacheClear(modulePath, logger, "Cleared parent cache")
}
}
/**
* Find all parent modules that depend on the target path
*/
private findParentModules(targetPath: string): Set<string> {
const parents = new Set<string>()
for (const [modulePath, moduleEntry] of Object.entries(require.cache)) {
if (this.shouldExcludePath(modulePath)) {
continue
}
if (moduleEntry?.children?.some((child) => child.id === targetPath)) {
parents.add(modulePath)
}
}
return parents
}
/**
* Log cache clearing operation
*/
private logCacheClear(
modulePath: string,
logger: Logger | undefined,
message: string
): void {
if (logger) {
const relativePath = path.relative(process.cwd(), modulePath)
logger.debug(`${this.logSource} ${message}: ${relativePath}`)
}
}
/**
* Clear require cache for a file and all its parent/descendant modules
*/
async clear(
filePath: string,
logger?: Logger,
onClear?: (modulePath: string) => Promise<void>,
trackBroken: boolean = true
): Promise<number> {
const absolutePath = path.resolve(filePath)
const visitedModules = new Set<string>()
// Clear parents first, then descendants
await this.clearParentModules(
absolutePath,
visitedModules,
logger,
onClear,
trackBroken
)
await this.clearDescendantModules(
absolutePath,
visitedModules,
logger,
onClear
)
if (logger) {
const relativePath = path.relative(process.cwd(), filePath)
logger.info(
`${this.logSource} Cleared ${visitedModules.size} module(s) from cache for ${relativePath}`
)
}
return visitedModules.size
}
/**
* Remove a module from the broken modules set
*/
removeBrokenModule(modulePath: string): void {
this.brokenModules.delete(modulePath)
}
/**
* Get all broken module paths
*/
getBrokenModules(): string[] {
return Array.from(this.brokenModules)
}
/**
* Get the count of broken modules
*/
getBrokenModuleCount(): number {
return this.brokenModules.size
}
/**
* Clear a specific module from require cache
*/
clearSingleModule(modulePath: string): void {
delete require.cache[modulePath]
}
}
@@ -0,0 +1,88 @@
import { Logger } from "@medusajs/framework/types"
import { ModuleCacheManager } from "./module-cache-manager"
import path from "path"
import { ReloadParams } from "./types"
/**
* Handles recovery of broken modules after dependencies are restored
*/
export class RecoveryService {
constructor(
private cacheManager: ModuleCacheManager,
private reloadResources: (params: ReloadParams) => Promise<void>,
private logSource: string,
private logger: Logger,
private rootDirectory: string
) {}
/**
* Attempt to recover all broken modules
*/
async recoverBrokenModules(): Promise<void> {
const brokenCount = this.cacheManager.getBrokenModuleCount()
if (!brokenCount) {
return
}
this.logger.info(
`${this.logSource} Attempting to recover ${brokenCount} broken module(s)`
)
const brokenModules = this.cacheManager.getBrokenModules()
for (const modulePath of brokenModules) {
await this.attemptModuleRecovery(modulePath)
}
this.logRecoveryResults()
}
/**
* Attempt to recover a single broken module
*/
private async attemptModuleRecovery(modulePath: string): Promise<void> {
this.cacheManager.clearSingleModule(modulePath)
const relativePath = path.relative(process.cwd(), modulePath)
this.logger.info(`${this.logSource} Attempting to reload: ${relativePath}`)
try {
// Attempt reload with skipRecovery=true to prevent infinite recursion
await this.reloadResources({
logSource: this.logSource,
action: "change",
absoluteFilePath: modulePath,
keepCache: false,
logger: this.logger,
skipRecovery: true,
rootDirectory: this.rootDirectory,
})
this.cacheManager.removeBrokenModule(modulePath)
this.logger.info(
`${this.logSource} Successfully recovered: ${relativePath}`
)
} catch (error) {
this.logger.debug(
`${this.logSource} Could not recover ${relativePath}: ${error}`
)
}
}
/**
* Log final recovery results
*/
private logRecoveryResults(): void {
const remainingBroken = this.cacheManager.getBrokenModuleCount()
if (remainingBroken) {
this.logger.debug(
`${this.logSource} ${remainingBroken} module(s) remain broken. They may recover when additional dependencies are restored.`
)
} else {
this.logger.info(
`${this.logSource} All broken modules successfully recovered`
)
}
}
}
@@ -0,0 +1,54 @@
import { Logger } from "@medusajs/framework/types"
import { ModuleCacheManager } from "../module-cache-manager"
import { FileChangeAction } from "../types"
export class BaseReloader {
constructor(
private readonly cacheManager: ModuleCacheManager,
private readonly logSource: string,
private readonly logger: Logger
) {}
clearModuleCache(absoluteFilePath: string) {
const resolved = require.resolve(absoluteFilePath)
if (require.cache[resolved]) {
delete require.cache[resolved]
}
}
async clearParentChildModulesCache(
absoluteFilePath: string,
reloaders: Array<() => Promise<void>>,
reloadResources: (args: {
logSource: string
action: FileChangeAction
absoluteFilePath: string
keepCache: boolean
skipRecovery: boolean
logger: Logger
rootDirectory: string
}) => Promise<void>,
skipRecovery: boolean,
rootDirectory: string
): Promise<void> {
await this.cacheManager.clear(
absoluteFilePath,
this.logger,
async (modulePath) => {
// Create deferred reloader for each cleared module
reloaders.push(async () =>
reloadResources({
logSource: this.logSource,
action: "change",
absoluteFilePath: modulePath,
keepCache: true,
skipRecovery: true, // handled by the main caller
logger: this.logger,
rootDirectory,
})
)
},
!skipRecovery // Track broken modules unless we're in recovery mode
)
}
}
@@ -0,0 +1,85 @@
import { JobLoader } from "@medusajs/framework/jobs"
import { Logger, MedusaContainer } from "@medusajs/framework/types"
import { ModuleCacheManager } from "../module-cache-manager"
import { ResourceRegistry } from "../resource-registry"
import { CONFIG, DevServerGlobals, FileChangeAction } from "../types"
import { BaseReloader } from "./base"
/**
* Metadata for a registered subscriber
*/
interface JobMetadata {
name: string
[key: string]: any
}
export class JobReloader extends BaseReloader {
#logSource: string
#logger: Logger
constructor(
private workflowManager: DevServerGlobals["WorkflowManager"],
cacheManager: ModuleCacheManager,
private container: MedusaContainer,
private registry: ResourceRegistry,
logSource: string,
logger: Logger
) {
super(cacheManager, logSource, logger)
this.#logSource = logSource
this.#logger = logger
}
/**
* Check if a file path represents a subscriber
*/
private isJobPath(filePath: string): boolean {
return filePath.includes(CONFIG.RESOURCE_PATH_PATTERNS.job)
}
/**
* Unregister a subscriber from the event-bus
*/
private unregisterJob(metadata: JobMetadata): void {
this.workflowManager?.unregister(metadata.name)
this.#logger.debug(`${this.#logSource} Unregistered job ${metadata.name}`)
}
/**
* Register a subscriber by loading the file and extracting its metadata
*/
private async registerJob(absoluteFilePath: string) {
const jobLoader = new JobLoader([], this.container)
await jobLoader.loadFile(absoluteFilePath)
this.#logger.debug(`${this.#logSource} Registered job ${absoluteFilePath}`)
}
/**
* Reload a subscriber file if necessary
*/
async reload(
action: FileChangeAction,
absoluteFilePath: string
): Promise<void> {
if (!this.isJobPath(absoluteFilePath)) {
return
}
const existingResources = this.registry.getResources(absoluteFilePath)
if (existingResources) {
for (const [_, resources] of existingResources) {
for (const resource of resources) {
this.unregisterJob({
name: resource.id,
config: resource.config,
})
}
}
}
if (action === "add" || action === "change") {
this.clearModuleCache(absoluteFilePath)
await this.registerJob(absoluteFilePath)
}
}
}
@@ -0,0 +1,265 @@
import { container, MedusaAppLoader } from "@medusajs/framework"
import { IModuleService, Logger } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
dynamicImport,
} from "@medusajs/framework/utils"
import { join, relative } from "path"
import { ModuleCacheManager } from "../module-cache-manager"
import { CONFIG, FileChangeAction } from "../types"
import { BaseReloader } from "./base"
import { HMRReloadError } from "../errors"
/**
* Handles hot reloading of custom modules in the /modules directory
*/
export class ModuleReloader extends BaseReloader {
#logSource: string
#logger: Logger
#rootDirectory: string
constructor(
cacheManager: ModuleCacheManager,
rootDirectory: string,
logSource: string,
logger: Logger
) {
super(cacheManager, logSource, logger)
this.#logSource = logSource
this.#logger = logger
this.#rootDirectory = rootDirectory
}
/**
* Check if a file path is within a module directory
*/
private isModulePath(filePath: string): boolean {
return filePath.includes("modules/")
}
/**
* Extract module name from file path
* e.g., "/path/to/project/modules/contact-us/service.ts" -> "contact-us"
*/
private getModuleNameFromPath(filePath: string): string | null {
const modulesPattern = "modules/"
const parts = filePath.split(modulesPattern)
if (parts.length < 2) {
return null
}
const afterModules = parts[1]
const moduleName = afterModules.split("/")[0]
return moduleName || null
}
/**
* Get the module directory path
*/
private getModuleDirectory(moduleName: string): string {
return join(this.#rootDirectory, "src", "modules", moduleName)
}
/**
* Get module key and service name from config
*/
private async getModuleInfo(moduleName: string): Promise<{
moduleKey: string
serviceName: string
} | null> {
try {
const configModule = container.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
if (!configModule?.modules) {
return null
}
// Find the module in config
for (const [key, config] of Object.entries(configModule.modules)) {
if (typeof config === "object" && config !== null) {
const resolvedPath = (config as any).resolve
if (
resolvedPath &&
(resolvedPath.includes(`/modules/${moduleName}`) ||
resolvedPath === `./modules/${moduleName}`)
) {
// Load the module to get serviceName from joinerConfig
const moduleDirectory = this.getModuleDirectory(moduleName)
const moduleIndexPath = join(moduleDirectory, "index.ts")
const moduleExports = await dynamicImport(moduleIndexPath)
const moduleService =
moduleExports.service ?? moduleExports.default?.service
const joinerConfig =
typeof moduleService?.prototype?.__joinerConfig === "function"
? moduleService.prototype.__joinerConfig()
: moduleService?.prototype?.__joinerConfig
if (!joinerConfig?.serviceName) {
return null
}
return {
moduleKey: key,
serviceName: joinerConfig.serviceName,
}
}
}
}
return null
} catch (error: any) {
this.#logger.warn(
`${this.#logSource} Failed to get module info for "${moduleName}": ${
error.message
}`
)
return null
}
}
/**
* Shutdown a module instance by calling its lifecycle hooks
*/
private async shutdownModule(moduleInstance: any): Promise<void> {
try {
if (moduleInstance?.__hooks?.onApplicationPrepareShutdown) {
await moduleInstance.__hooks.onApplicationPrepareShutdown
.bind(moduleInstance)()
.catch(() => {})
}
if (moduleInstance?.__hooks?.onApplicationShutdown) {
await moduleInstance.__hooks.onApplicationShutdown
.bind(moduleInstance)()
.catch(() => {})
}
} catch (error) {
this.#logger.warn(
`${this.#logSource} Error during module shutdown: ${error.message}`
)
}
}
/**
* Clear all module files from require cache
*/
private clearModuleFilesCache(moduleDirectory: string): void {
const relativeModuleDirectory = relative(
this.#rootDirectory,
moduleDirectory
)
Object.keys(require.cache).forEach((cachedPath) => {
if (
!CONFIG.EXCLUDED_PATH_PATTERNS.some((pattern) =>
cachedPath.includes(pattern)
) &&
cachedPath.includes(relativeModuleDirectory)
) {
delete require.cache[cachedPath]
}
})
}
/**
* Reload a module when its files change
*/
async reload(
action: FileChangeAction,
absoluteFilePath: string
): Promise<void> {
if (!this.isModulePath(absoluteFilePath)) {
return
}
const moduleName = this.getModuleNameFromPath(absoluteFilePath)
if (!moduleName) {
this.#logger.warn(
`${
this.#logSource
} Could not determine module name from path: ${absoluteFilePath}`
)
return
}
const relativePath = relative(this.#rootDirectory, absoluteFilePath)
if (action === "unlink") {
this.#logger.warn(
`${
this.#logSource
} Module file removed: ${relativePath}. Server restart may be required.`
)
throw new HMRReloadError(
`Module file removed: ${relativePath}. Server restart may be required.`
)
}
if (absoluteFilePath.includes("migrations")) {
this.#logger.warn(
`${
this.#logSource
} Migrations file changed: ${relativePath}. You may need to apply migrations and restart the server.`
)
return
}
// Get the module information
const moduleInfo = await this.getModuleInfo(moduleName)
if (!moduleInfo) {
this.#logger.warn(
`${this.#logSource} Could not find module config for: ${moduleName}`
)
return
}
const { moduleKey, serviceName } = moduleInfo
this.#logger.info(
`${
this.#logSource
} Reloading module "${serviceName}" (${moduleName}) due to change in ${relativePath}`
)
try {
// Get the current module instance
const moduleInstance = container.resolve(serviceName) as any
if (moduleInstance) {
// Shutdown the module
await this.shutdownModule(moduleInstance)
}
const moduleDirectory = this.getModuleDirectory(moduleName)
this.clearModuleFilesCache(moduleDirectory)
const medusaAppLoader = new MedusaAppLoader()
const newModuleInstance = (await medusaAppLoader.reloadSingleModule({
moduleKey,
serviceName,
})) as unknown as IModuleService
if (!newModuleInstance) {
throw new Error(`Failed to reload module "${moduleKey}"`)
}
this.#logger.info(
`${this.#logSource} Successfully reloaded module "${serviceName}"`
)
} catch (error: any) {
this.#logger.error(
`${this.#logSource} Failed to reload module "${serviceName}": ${
error.message
}`
)
throw new HMRReloadError(
`Failed to reload module "${serviceName}": ${error.message}. Server restart may be required.`
)
}
}
}
@@ -0,0 +1,69 @@
import { ApiLoader } from "@medusajs/framework"
import { Logger } from "@medusajs/framework/types"
import { CONFIG, FileChangeAction } from "../types"
import { ModuleCacheManager } from "../module-cache-manager"
import { BaseReloader } from "./base"
/**
* Handles hot reloading of API resources (routes, middlewares, validators, etc.)
*/
export class RouteReloader extends BaseReloader {
#cacheManager: ModuleCacheManager
#logSource: string
#logger: Logger
constructor(
private apiLoader: ApiLoader | undefined,
cacheManager: ModuleCacheManager,
logSource: string,
logger: Logger
) {
super(cacheManager, logSource, logger)
this.#cacheManager = cacheManager
this.#logSource = logSource
this.#logger = logger
}
/**
* Check if a file path is in the API directory
*/
private isApiPath(filePath: string): boolean {
return filePath.includes(CONFIG.RESOURCE_PATH_PATTERNS.route)
}
/**
* Reload ALL API resources when any API file changes
* This clears all Express routes/middleware and reloads everything from scratch
*/
async reload(
_action: FileChangeAction,
absoluteFilePath: string
): Promise<void> {
if (!this.isApiPath(absoluteFilePath)) {
return
}
if (!this.apiLoader) {
this.#logger.error(
`${this.#logSource} ApiLoader not available - cannot reload API`
)
return
}
this.#logger.info(
`${this.#logSource} API change detected: ${absoluteFilePath}`
)
await this.#cacheManager.clear(
absoluteFilePath,
this.#logger,
undefined,
false // Don't track as broken since we're intentionally reloading
)
this.apiLoader.clearAllResources()
await this.apiLoader.load()
this.#logger.info(`${this.#logSource} API resources reloaded successfully`)
}
}
@@ -0,0 +1,147 @@
import { SubscriberLoader } from "@medusajs/framework/subscribers"
import {
IEventBusModuleService,
Logger,
MedusaContainer,
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { ResourceRegistry } from "../resource-registry"
import { CONFIG, FileChangeAction } from "../types"
import { BaseReloader } from "./base"
import { ModuleCacheManager } from "../module-cache-manager"
/**
* Metadata for a registered subscriber
*/
interface SubscriberMetadata {
subscriberId: string
events: string[]
}
/**
* Handles hot reloading of subscriber files with event-bus unregistration
*/
export class SubscriberReloader extends BaseReloader {
#eventBusService: IEventBusModuleService | undefined
#logSource: string
#logger: Logger
constructor(
private container: MedusaContainer,
cacheManager: ModuleCacheManager,
private registry: ResourceRegistry,
logSource: string,
logger: Logger
) {
super(cacheManager, logSource, logger)
this.#logSource = logSource
this.#logger = logger
this.#eventBusService = container.resolve(Modules.EVENT_BUS, {
allowUnregistered: true,
}) as IEventBusModuleService
}
/**
* Check if a file path represents a subscriber
*/
private isSubscriberPath(filePath: string): boolean {
return filePath.includes(CONFIG.RESOURCE_PATH_PATTERNS.subscriber)
}
/**
* Unregister a subscriber from the event-bus
*/
private unregisterSubscriber(metadata: SubscriberMetadata): void {
if (!this.#eventBusService) {
return
}
for (const event of metadata.events) {
// Create a dummy subscriber function - the event-bus will use subscriberId to find the real one
const dummySubscriber = async () => {}
;(dummySubscriber as any).subscriberId = metadata.subscriberId
this.#eventBusService.unsubscribe(event, dummySubscriber as any, {
subscriberId: metadata.subscriberId,
})
}
this.#logger.debug(
`${this.#logSource} Unregistered subscriber ${
metadata.subscriberId
} from events: ${metadata.events.join(", ")}`
)
}
/**
* Register a subscriber by loading the file and extracting its metadata
*/
private registerSubscriber(absoluteFilePath: string): void {
if (!this.#eventBusService) {
return
}
try {
// Load the subscriber module
const subscriberModule = require(absoluteFilePath)
new SubscriberLoader(
absoluteFilePath,
{},
this.container
).createSubscriber({
fileName: absoluteFilePath,
config: subscriberModule.config,
handler: subscriberModule.default,
})
this.#logger.debug(
`${this.#logSource} Registered subscriber ${absoluteFilePath}`
)
} catch (error) {
this.#logger.error(
`${
this.#logSource
} Failed to register subscriber from ${absoluteFilePath}: ${error}`
)
}
}
/**
* Reload a subscriber file if necessary
*/
async reload(
action: FileChangeAction,
absoluteFilePath: string
): Promise<void> {
if (!this.isSubscriberPath(absoluteFilePath)) {
return
}
if (!this.#eventBusService) {
this.#logger.error(
`${
this.#logSource
} EventBusService not available - cannot reload subscribers`
)
return
}
const existingResources = this.registry.getResources(absoluteFilePath)
if (existingResources) {
for (const [_, resources] of existingResources) {
for (const resource of resources) {
this.unregisterSubscriber({
subscriberId: resource.id,
events: resource.events,
})
}
}
}
if (action === "add" || action === "change") {
this.clearModuleCache(absoluteFilePath)
this.registerSubscriber(absoluteFilePath)
}
}
}
@@ -0,0 +1,178 @@
import { Logger } from "@medusajs/framework/types"
import { ModuleCacheManager } from "../module-cache-manager"
import { ResourceRegistry } from "../resource-registry"
import {
CONFIG,
DevServerGlobals,
ReloadParams,
FileChangeAction,
} from "../types"
import { ResourceEntry, ResourceMap } from "@medusajs/framework/utils"
import { BaseReloader } from "./base"
/**
* Handles hot reloading of workflow and step files
*/
export class WorkflowReloader extends BaseReloader {
#logSource: string
#logger: Logger
#rootDirectory: string
constructor(
private workflowManager: DevServerGlobals["WorkflowManager"],
cacheManager: ModuleCacheManager,
private registry: ResourceRegistry,
private reloadResources: (params: ReloadParams) => Promise<void>,
logSource: string,
logger: Logger,
rootDirectory: string
) {
super(cacheManager, logSource, logger)
this.#logSource = logSource
this.#logger = logger
this.#rootDirectory = rootDirectory
}
/**
* Check if a file path represents a workflow
*/
private isWorkflowPath(filePath: string): boolean {
return filePath.includes(CONFIG.RESOURCE_PATH_PATTERNS.workflow)
}
/**
* Reload a workflow file if necessary
*/
async reload(
action: FileChangeAction,
absoluteFilePath: string,
keepCache: boolean = false,
skipRecovery: boolean = false
): Promise<void> {
if (!this.isWorkflowPath(absoluteFilePath)) {
return
}
if (!this.workflowManager) {
this.#logger.error(
`${
this.#logSource
} WorkflowManager not available - cannot reload workflows`
)
return
}
const requirableWorkflowPaths = new Set<string>()
const reloaders: Array<() => Promise<void>> = []
// Unregister resources and collect affected workflows
this.unregisterResources(absoluteFilePath, requirableWorkflowPaths)
if (!keepCache) {
await this.clearParentChildModulesCache(
absoluteFilePath,
reloaders,
this.reloadResources,
skipRecovery,
this.#rootDirectory
)
}
this.clearModuleCache(absoluteFilePath)
// Reload workflows that were affected
if (action !== "unlink") {
this.reloadWorkflowModules(requirableWorkflowPaths, absoluteFilePath)
}
// Execute deferred reloaders
if (reloaders.length) {
await Promise.all(reloaders.map(async (reloader) => reloader()))
}
}
/**
* Unregister workflow and step resources
*/
private unregisterResources(
absoluteFilePath: string,
affectedWorkflows: Set<string>
): void {
const resources = this.registry.getResources(absoluteFilePath)
if (!resources) {
return
}
for (const [type, resourceList] of resources.entries()) {
for (const resource of resourceList) {
if (type === "workflow") {
this.workflowManager!.unregister(resource.id)
} else if (type === "step") {
this.handleStepUnregister(resource, affectedWorkflows)
}
}
}
}
/**
* Handle unregistering a step and find affected workflows
*/
private handleStepUnregister(
stepResource: ResourceEntry,
affectedWorkflows: Set<string>
): void {
const workflowSourcePaths = this.registry.getWorkflowSourcePaths(
stepResource.id
)
if (!workflowSourcePaths) {
return
}
for (const sourcePath of workflowSourcePaths) {
const workflowResources = this.registry.getResources(sourcePath)
if (!workflowResources) {
continue
}
this.unregisterWorkflowsInResource(
workflowResources,
affectedWorkflows,
sourcePath
)
}
}
/**
* Unregister workflows found in a resource and track their paths
*/
private unregisterWorkflowsInResource(
workflowResources: ResourceMap,
affectedWorkflows: Set<string>,
sourcePath: string
): void {
for (const [type, resourceList] of workflowResources.entries()) {
if (type !== "workflow") {
continue
}
for (const workflow of resourceList) {
this.workflowManager!.unregister(workflow.id)
affectedWorkflows.add(sourcePath)
}
}
}
/**
* Reload workflow modules using require
*/
private reloadWorkflowModules(
workflowPaths: Set<string>,
mainFilePath: string
): void {
for (const workflowPath of workflowPaths) {
require(workflowPath)
}
require(mainFilePath)
}
}
@@ -0,0 +1,22 @@
import {
globalDevServerRegistry,
inverseDevServerRegistry,
ResourceMap,
ResourcePath,
} from "@medusajs/framework/utils"
export class ResourceRegistry {
/**
* Get resources registered for a given file path
*/
getResources(filePath: string): ResourceMap | undefined {
return globalDevServerRegistry.get(filePath)
}
/**
* Get workflow source paths for a step resource
*/
getWorkflowSourcePaths(stepId: string): ResourcePath[] | undefined {
return inverseDevServerRegistry.get(`step:${stepId}`)
}
}
@@ -0,0 +1,56 @@
import { ApiLoader } from "@medusajs/framework"
import { Logger } from "@medusajs/framework/types"
/**
* Action types that can be performed on files
*/
export type FileChangeAction = "add" | "change" | "unlink"
/**
* Configuration for path matching and exclusions
*/
export const CONFIG = {
EXCLUDED_PATH_PATTERNS: ["node_modules"],
RESOURCE_PATH_PATTERNS: {
route: "api/",
workflow: "workflows/",
subscriber: "subscribers/",
job: "jobs/",
module: "modules/",
},
} as const
/**
* Global dependencies available in the dev server environment
*/
export interface DevServerGlobals {
__MEDUSA_HMR_API_LOADER__?: ApiLoader
__MEDUSA_HMR_INITIAL_STACK_LENGTH__?: number
WorkflowManager?: {
unregister: (id: string) => void
}
}
/**
* Parameters for resource reload operations
*/
export interface ReloadParams {
/**
* The source of the log, used to prefix the log messages
*/
logSource: string
action: FileChangeAction
absoluteFilePath: string
keepCache?: boolean
logger: Logger
skipRecovery?: boolean
rootDirectory: string
}
/**
* Represents a resource registered in the dev server
*/
export interface Resource {
id: string
[key: string]: any
}
@@ -0,0 +1,13 @@
import { FlagSettings } from "@medusajs/framework/feature-flags"
const BackendHmrFeatureFlag: FlagSettings = {
key: "backend_hmr",
default_val: false,
env_key: "MEDUSA_FF_BACKEND_HMR",
description:
"Enable experimental Hot Module Replacement (HMR) for backend development. " +
"When enabled, route, middleware, workflows and steps changes reload in <10ms without restarting the server. " +
"Database connections and container state persist across reloads.",
}
export default BackendHmrFeatureFlag
+7
View File
@@ -1,6 +1,7 @@
import { ConfigModule } from "@medusajs/framework/config"
import { ApiLoader } from "@medusajs/framework/http"
import { MedusaContainer, PluginDetails } from "@medusajs/framework/types"
import { FeatureFlag } from "@medusajs/framework/utils"
import { Express } from "express"
import { join } from "path"
import qs from "qs"
@@ -24,6 +25,12 @@ export default async ({ app, container, plugins }: Options) => {
next()
})
// Store the initial router stack length before loading API resources for HMR
if (FeatureFlag.isFeatureEnabled("backend_hmr")) {
const initialStackLength = (app as any)._router?.stack?.length ?? 0
;(global as any).__MEDUSA_HMR_INITIAL_STACK_LENGTH__ = initialStackLength
}
const sourcePaths: string[] = []
/**