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:
Adrien de Peretti
2025-12-08 09:48:36 +01:00
committed by GitHub
parent 4de555b546
commit fe49b567d6
38 changed files with 2222 additions and 61 deletions

View File

@@ -1,6 +1,11 @@
import { ContainerRegistrationKeys, parseCorsOrigins } from "@medusajs/utils"
import { ContainerRegistrationKeys, parseCorsOrigins, FeatureFlag } from "@medusajs/utils"
import cors, { CorsOptions } from "cors"
import type { ErrorRequestHandler, Express, RequestHandler } from "express"
import type {
ErrorRequestHandler,
Express,
IRouter,
RequestHandler,
} from "express"
import type {
AdditionalDataValidatorRoute,
BodyParserConfigRoute,
@@ -83,6 +88,7 @@ export class ApiLoader {
*/
async #loadHttpResources() {
const routesLoader = new RoutesLoader()
const middlewareLoader = new MiddlewareFileLoader()
for (const dir of this.#sourceDirs) {
@@ -119,6 +125,7 @@ export class ApiLoader {
: route.handler
this.#app[route.method.toLowerCase()](route.matcher, wrapHandler(handler))
return
}
@@ -354,6 +361,10 @@ export class ApiLoader {
}
async load() {
if (FeatureFlag.isFeatureEnabled("backend_hmr")) {
;(global as any).__MEDUSA_HMR_API_LOADER__ = this
}
const {
errorHandler: sourceErrorHandler,
middlewares,
@@ -462,4 +473,19 @@ export class ApiLoader {
*/
this.#app.use(sourceErrorHandler ?? errorHandler())
}
/**
* Clear all API resources registered by this loader
* This removes all routes and middleware added after the initial stack state
* Used by HMR to reset the API state before reloading
*/
clearAllResources() {
const router = this.#app._router as IRouter
const initialStackLength =
(global as any).__MEDUSA_HMR_INITIAL_STACK_LENGTH__ ?? 0
if (router && router.stack) {
router.stack.splice(initialStackLength)
}
}
}

View File

@@ -54,7 +54,7 @@ export class RoutesLoader {
/**
* Creates the route path from its relative file path.
*/
#createRoutePath(relativePath: string): string {
createRoutePath(relativePath: string): string {
const segments = relativePath.replace(/route(\.js|\.ts)$/, "").split(sep)
const params: Record<string, boolean> = {}
@@ -186,7 +186,7 @@ export class RoutesLoader {
.map(async (entry) => {
const absolutePath = join(entry.path, entry.name)
const relativePath = absolutePath.replace(sourceDir, "")
const route = this.#createRoutePath(relativePath)
const route = this.createRoutePath(relativePath)
const routes = await this.#getRoutesForFile(route, absolutePath)
routes.forEach((routeConfig) => {
@@ -233,4 +233,32 @@ export class RoutesLoader {
[]
)
}
/**
* Reload a single route file
* This is used by HMR to reload routes when files change
*/
async reloadRouteFile(
absolutePath: string,
sourceDir: string
): Promise<RouteDescriptor[]> {
const relativePath = absolutePath.replace(sourceDir, "")
const route = this.createRoutePath(relativePath)
const routes = await this.#getRoutesForFile(route, absolutePath)
// Register the new routes (will overwrite existing)
routes.forEach((routeConfig) => {
this.registerRoute({
absolutePath,
relativePath,
...routeConfig,
})
})
return routes.map((routeConfig) => ({
absolutePath,
relativePath,
...routeConfig,
}))
}
}

View File

@@ -1,6 +1,12 @@
import type { SchedulerOptions } from "@medusajs/orchestration"
import { MedusaContainer } from "@medusajs/types"
import { isFileSkipped, isObject, MedusaError } from "@medusajs/utils"
import {
dynamicImport,
isFileSkipped,
isObject,
MedusaError,
registerDevServerResource,
} from "@medusajs/utils"
import {
createStep,
createWorkflow,
@@ -23,6 +29,11 @@ export class JobLoader extends ResourceLoader {
super(sourceDir, container)
}
async loadFile(path: string) {
const exports = await dynamicImport(path)
await this.onFileLoaded(path, exports)
}
protected async onFileLoaded(
path: string,
fileExports: {
@@ -37,6 +48,7 @@ export class JobLoader extends ResourceLoader {
this.validateConfig(fileExports.config)
this.logger.debug(`Registering job from ${path}.`)
this.register({
path,
config: fileExports.config,
handler: fileExports.default,
})
@@ -80,9 +92,11 @@ export class JobLoader extends ResourceLoader {
* @protected
*/
protected register({
path,
config,
handler,
}: {
path: string
config: CronJobConfig
handler: CronJobHandler
}) {
@@ -116,6 +130,13 @@ export class JobLoader extends ResourceLoader {
createWorkflow(workflowConfig, () => {
step()
})
registerDevServerResource({
sourcePath: path,
id: workflowName,
type: "job",
config: config,
})
}
/**

View File

@@ -5,6 +5,7 @@ import {
MedusaAppMigrateGenerate,
MedusaAppMigrateUp,
MedusaAppOutput,
MedusaModule,
ModulesDefinition,
RegisterModuleJoinerConfig,
} from "@medusajs/modules-sdk"
@@ -12,6 +13,7 @@ import {
CommonTypes,
ConfigModule,
ILinkMigrationsPlanner,
IModuleService,
InternalModuleDeclaration,
LoadedModule,
ModuleDefinition,
@@ -235,6 +237,76 @@ export class MedusaAppLoader {
})
}
/**
* Reload a single module by its key
* @param moduleKey - The key of the module to reload (e.g., 'contactUsModuleService')
*/
async reloadSingleModule({
moduleKey,
serviceName,
}: {
/**
* the key of the module to reload in the medusa config (either infered or specified)
*/
moduleKey: string
/**
* Registration name of the service to reload in the container
*/
serviceName: string
}): Promise<LoadedModule | null> {
const configModule: ConfigModule = this.#container.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
MedusaModule.unregisterModuleResolution(moduleKey)
if (serviceName) {
this.#container.cache.delete(serviceName)
}
const moduleConfig = configModule.modules?.[moduleKey]
if (!moduleConfig) {
return null
}
const { sharedResourcesConfig, injectedDependencies } =
this.prepareSharedResourcesAndDeps()
const mergedModules = this.mergeDefaultModules({
[moduleKey]: moduleConfig,
})
const moduleDefinition = mergedModules[moduleKey]
const result = await MedusaApp({
modulesConfig: { [moduleKey]: moduleDefinition },
sharedContainer: this.#container,
linkModules: this.#customLinksModules,
sharedResourcesConfig,
injectedDependencies,
workerMode: configModule.projectConfig?.workerMode,
medusaConfigPath: this.#medusaConfigPath,
cwd: this.#cwd,
})
const loadedModule = result.modules[moduleKey] as LoadedModule &
IModuleService
if (loadedModule) {
this.#container.register({
[loadedModule.__definition.key]: asValue(loadedModule),
})
}
if (loadedModule?.__hooks?.onApplicationStart) {
await loadedModule.__hooks.onApplicationStart
.bind(loadedModule)()
.catch((error: any) => {
injectedDependencies[ContainerRegistrationKeys.LOGGER].error(
`Error starting module "${loadedModule.__definition.key}": ${error.message}`
)
})
}
return loadedModule
}
/**
* Load all modules and bootstrap all the modules and links to be ready to be consumed
* @param config

View File

@@ -4,7 +4,12 @@ import {
MedusaContainer,
Subscriber,
} from "@medusajs/types"
import { isFileSkipped, kebabCase, Modules } from "@medusajs/utils"
import {
isFileSkipped,
kebabCase,
Modules,
registerDevServerResource,
} from "@medusajs/utils"
import { parse } from "path"
import { configManager } from "../config"
import { container } from "../container"
@@ -154,7 +159,7 @@ export class SubscriberLoader extends ResourceLoader {
return kebabCase(idFromFile)
}
private createSubscriber<T = unknown>({
createSubscriber<T = unknown>({
fileName,
config,
handler,
@@ -186,6 +191,14 @@ export class SubscriberLoader extends ResourceLoader {
...config.context,
subscriberId,
})
registerDevServerResource({
type: "subscriber",
id: subscriberId,
sourcePath: fileName,
subscriberId,
events,
})
}
}