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:
38 files changed
+2222
-61
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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[] = []
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user