Skip to content

Commit fe49b56

Browse files
adrien2polivermrblcarlos-r-l-rodrigues
authored
chore: Backend HMR (expriemental) (medusajs#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 <[email protected]> Co-authored-by: Carlos R. L. Rodrigues <[email protected]>
1 parent 4de555b commit fe49b56

File tree

38 files changed

+2222
-61
lines changed

38 files changed

+2222
-61
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@medusajs/medusa": patch
3+
"@medusajs/event-bus-local": patch
4+
"@medusajs/framework": patch
5+
"@medusajs/utils": patch
6+
"@medusajs/workflows-sdk": patch
7+
---
8+
9+
chore: Backend HMR (expriemental)

packages/core/framework/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,7 @@
9999
"@aws-sdk/client-dynamodb": "^3.218.0",
100100
"@medusajs/cli": "2.12.1",
101101
"connect-dynamodb": "^3.0.5",
102-
"ioredis": "^5.4.1",
103-
"vite": "^5.4.21"
102+
"ioredis": "^5.4.1"
104103
},
105104
"peerDependenciesMeta": {
106105
"@aws-sdk/client-dynamodb": {

packages/core/framework/src/http/router.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { ContainerRegistrationKeys, parseCorsOrigins } from "@medusajs/utils"
1+
import { ContainerRegistrationKeys, parseCorsOrigins, FeatureFlag } from "@medusajs/utils"
22
import cors, { CorsOptions } from "cors"
3-
import type { ErrorRequestHandler, Express, RequestHandler } from "express"
3+
import type {
4+
ErrorRequestHandler,
5+
Express,
6+
IRouter,
7+
RequestHandler,
8+
} from "express"
49
import type {
510
AdditionalDataValidatorRoute,
611
BodyParserConfigRoute,
@@ -83,6 +88,7 @@ export class ApiLoader {
8388
*/
8489
async #loadHttpResources() {
8590
const routesLoader = new RoutesLoader()
91+
8692
const middlewareLoader = new MiddlewareFileLoader()
8793

8894
for (const dir of this.#sourceDirs) {
@@ -119,6 +125,7 @@ export class ApiLoader {
119125
: route.handler
120126

121127
this.#app[route.method.toLowerCase()](route.matcher, wrapHandler(handler))
128+
122129
return
123130
}
124131

@@ -354,6 +361,10 @@ export class ApiLoader {
354361
}
355362

356363
async load() {
364+
if (FeatureFlag.isFeatureEnabled("backend_hmr")) {
365+
;(global as any).__MEDUSA_HMR_API_LOADER__ = this
366+
}
367+
357368
const {
358369
errorHandler: sourceErrorHandler,
359370
middlewares,
@@ -462,4 +473,19 @@ export class ApiLoader {
462473
*/
463474
this.#app.use(sourceErrorHandler ?? errorHandler())
464475
}
476+
477+
/**
478+
* Clear all API resources registered by this loader
479+
* This removes all routes and middleware added after the initial stack state
480+
* Used by HMR to reset the API state before reloading
481+
*/
482+
clearAllResources() {
483+
const router = this.#app._router as IRouter
484+
const initialStackLength =
485+
(global as any).__MEDUSA_HMR_INITIAL_STACK_LENGTH__ ?? 0
486+
487+
if (router && router.stack) {
488+
router.stack.splice(initialStackLength)
489+
}
490+
}
465491
}

packages/core/framework/src/http/routes-loader.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class RoutesLoader {
5454
/**
5555
* Creates the route path from its relative file path.
5656
*/
57-
#createRoutePath(relativePath: string): string {
57+
createRoutePath(relativePath: string): string {
5858
const segments = relativePath.replace(/route(\.js|\.ts)$/, "").split(sep)
5959
const params: Record<string, boolean> = {}
6060

@@ -186,7 +186,7 @@ export class RoutesLoader {
186186
.map(async (entry) => {
187187
const absolutePath = join(entry.path, entry.name)
188188
const relativePath = absolutePath.replace(sourceDir, "")
189-
const route = this.#createRoutePath(relativePath)
189+
const route = this.createRoutePath(relativePath)
190190
const routes = await this.#getRoutesForFile(route, absolutePath)
191191

192192
routes.forEach((routeConfig) => {
@@ -233,4 +233,32 @@ export class RoutesLoader {
233233
[]
234234
)
235235
}
236+
237+
/**
238+
* Reload a single route file
239+
* This is used by HMR to reload routes when files change
240+
*/
241+
async reloadRouteFile(
242+
absolutePath: string,
243+
sourceDir: string
244+
): Promise<RouteDescriptor[]> {
245+
const relativePath = absolutePath.replace(sourceDir, "")
246+
const route = this.createRoutePath(relativePath)
247+
const routes = await this.#getRoutesForFile(route, absolutePath)
248+
249+
// Register the new routes (will overwrite existing)
250+
routes.forEach((routeConfig) => {
251+
this.registerRoute({
252+
absolutePath,
253+
relativePath,
254+
...routeConfig,
255+
})
256+
})
257+
258+
return routes.map((routeConfig) => ({
259+
absolutePath,
260+
relativePath,
261+
...routeConfig,
262+
}))
263+
}
236264
}

packages/core/framework/src/jobs/job-loader.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { SchedulerOptions } from "@medusajs/orchestration"
22
import { MedusaContainer } from "@medusajs/types"
3-
import { isFileSkipped, isObject, MedusaError } from "@medusajs/utils"
3+
import {
4+
dynamicImport,
5+
isFileSkipped,
6+
isObject,
7+
MedusaError,
8+
registerDevServerResource,
9+
} from "@medusajs/utils"
410
import {
511
createStep,
612
createWorkflow,
@@ -23,6 +29,11 @@ export class JobLoader extends ResourceLoader {
2329
super(sourceDir, container)
2430
}
2531

32+
async loadFile(path: string) {
33+
const exports = await dynamicImport(path)
34+
await this.onFileLoaded(path, exports)
35+
}
36+
2637
protected async onFileLoaded(
2738
path: string,
2839
fileExports: {
@@ -37,6 +48,7 @@ export class JobLoader extends ResourceLoader {
3748
this.validateConfig(fileExports.config)
3849
this.logger.debug(`Registering job from ${path}.`)
3950
this.register({
51+
path,
4052
config: fileExports.config,
4153
handler: fileExports.default,
4254
})
@@ -80,9 +92,11 @@ export class JobLoader extends ResourceLoader {
8092
* @protected
8193
*/
8294
protected register({
95+
path,
8396
config,
8497
handler,
8598
}: {
99+
path: string
86100
config: CronJobConfig
87101
handler: CronJobHandler
88102
}) {
@@ -116,6 +130,13 @@ export class JobLoader extends ResourceLoader {
116130
createWorkflow(workflowConfig, () => {
117131
step()
118132
})
133+
134+
registerDevServerResource({
135+
sourcePath: path,
136+
id: workflowName,
137+
type: "job",
138+
config: config,
139+
})
119140
}
120141

121142
/**

packages/core/framework/src/medusa-app-loader.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import {
55
MedusaAppMigrateGenerate,
66
MedusaAppMigrateUp,
77
MedusaAppOutput,
8+
MedusaModule,
89
ModulesDefinition,
910
RegisterModuleJoinerConfig,
1011
} from "@medusajs/modules-sdk"
1112
import {
1213
CommonTypes,
1314
ConfigModule,
1415
ILinkMigrationsPlanner,
16+
IModuleService,
1517
InternalModuleDeclaration,
1618
LoadedModule,
1719
ModuleDefinition,
@@ -235,6 +237,76 @@ export class MedusaAppLoader {
235237
})
236238
}
237239

240+
/**
241+
* Reload a single module by its key
242+
* @param moduleKey - The key of the module to reload (e.g., 'contactUsModuleService')
243+
*/
244+
async reloadSingleModule({
245+
moduleKey,
246+
serviceName,
247+
}: {
248+
/**
249+
* the key of the module to reload in the medusa config (either infered or specified)
250+
*/
251+
moduleKey: string
252+
/**
253+
* Registration name of the service to reload in the container
254+
*/
255+
serviceName: string
256+
}): Promise<LoadedModule | null> {
257+
const configModule: ConfigModule = this.#container.resolve(
258+
ContainerRegistrationKeys.CONFIG_MODULE
259+
)
260+
MedusaModule.unregisterModuleResolution(moduleKey)
261+
if (serviceName) {
262+
this.#container.cache.delete(serviceName)
263+
}
264+
265+
const moduleConfig = configModule.modules?.[moduleKey]
266+
if (!moduleConfig) {
267+
return null
268+
}
269+
270+
const { sharedResourcesConfig, injectedDependencies } =
271+
this.prepareSharedResourcesAndDeps()
272+
273+
const mergedModules = this.mergeDefaultModules({
274+
[moduleKey]: moduleConfig,
275+
})
276+
const moduleDefinition = mergedModules[moduleKey]
277+
278+
const result = await MedusaApp({
279+
modulesConfig: { [moduleKey]: moduleDefinition },
280+
sharedContainer: this.#container,
281+
linkModules: this.#customLinksModules,
282+
sharedResourcesConfig,
283+
injectedDependencies,
284+
workerMode: configModule.projectConfig?.workerMode,
285+
medusaConfigPath: this.#medusaConfigPath,
286+
cwd: this.#cwd,
287+
})
288+
289+
const loadedModule = result.modules[moduleKey] as LoadedModule &
290+
IModuleService
291+
if (loadedModule) {
292+
this.#container.register({
293+
[loadedModule.__definition.key]: asValue(loadedModule),
294+
})
295+
}
296+
297+
if (loadedModule?.__hooks?.onApplicationStart) {
298+
await loadedModule.__hooks.onApplicationStart
299+
.bind(loadedModule)()
300+
.catch((error: any) => {
301+
injectedDependencies[ContainerRegistrationKeys.LOGGER].error(
302+
`Error starting module "${loadedModule.__definition.key}": ${error.message}`
303+
)
304+
})
305+
}
306+
307+
return loadedModule
308+
}
309+
238310
/**
239311
* Load all modules and bootstrap all the modules and links to be ready to be consumed
240312
* @param config

packages/core/framework/src/subscribers/subscriber-loader.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
MedusaContainer,
55
Subscriber,
66
} from "@medusajs/types"
7-
import { isFileSkipped, kebabCase, Modules } from "@medusajs/utils"
7+
import {
8+
isFileSkipped,
9+
kebabCase,
10+
Modules,
11+
registerDevServerResource,
12+
} from "@medusajs/utils"
813
import { parse } from "path"
914
import { configManager } from "../config"
1015
import { container } from "../container"
@@ -154,7 +159,7 @@ export class SubscriberLoader extends ResourceLoader {
154159
return kebabCase(idFromFile)
155160
}
156161

157-
private createSubscriber<T = unknown>({
162+
createSubscriber<T = unknown>({
158163
fileName,
159164
config,
160165
handler,
@@ -186,6 +191,14 @@ export class SubscriberLoader extends ResourceLoader {
186191
...config.context,
187192
subscriberId,
188193
})
194+
195+
registerDevServerResource({
196+
type: "subscriber",
197+
id: subscriberId,
198+
sourcePath: fileName,
199+
subscriberId,
200+
events,
201+
})
189202
}
190203
}
191204

packages/core/modules-sdk/src/medusa-module.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,20 @@ class MedusaModule {
203203
return [...MedusaModule.moduleResolutions_.values()]
204204
}
205205

206+
public static unregisterModuleResolution(moduleKey: string): void {
207+
MedusaModule.moduleResolutions_.delete(moduleKey)
208+
MedusaModule.joinerConfig_.delete(moduleKey)
209+
const moduleAliases = MedusaModule.modules_
210+
.get(moduleKey)
211+
?.map((m) => m.alias || m.hash)
212+
if (moduleAliases) {
213+
for (const alias of moduleAliases) {
214+
MedusaModule.instances_.delete(alias)
215+
}
216+
}
217+
MedusaModule.modules_.delete(moduleKey)
218+
}
219+
206220
public static setModuleResolution(
207221
moduleKey: string,
208222
resolution: ModuleResolution
@@ -516,25 +530,27 @@ class MedusaModule {
516530
}
517531

518532
const resolvedServices = await promiseAll(
519-
loadedModules.map(async ({
520-
hashKey,
521-
modDeclaration,
522-
moduleResolutions,
523-
container,
524-
finishLoading,
525-
}) => {
526-
const service = await MedusaModule.resolveLoadedModule({
533+
loadedModules.map(
534+
async ({
527535
hashKey,
528536
modDeclaration,
529537
moduleResolutions,
530538
container,
531-
})
539+
finishLoading,
540+
}) => {
541+
const service = await MedusaModule.resolveLoadedModule({
542+
hashKey,
543+
modDeclaration,
544+
moduleResolutions,
545+
container,
546+
})
532547

533-
MedusaModule.instances_.set(hashKey, service)
534-
finishLoading(service)
535-
MedusaModule.loading_.delete(hashKey)
536-
return service
537-
})
548+
MedusaModule.instances_.set(hashKey, service)
549+
finishLoading(service)
550+
MedusaModule.loading_.delete(hashKey)
551+
return service
552+
}
553+
)
538554
)
539555

540556
services.push(...resolvedServices)
@@ -590,7 +606,10 @@ class MedusaModule {
590606

591607
try {
592608
// TODO: rework that to store on a separate property
593-
joinerConfig = await services[keyName].__joinerConfig?.()
609+
joinerConfig =
610+
typeof services[keyName].__joinerConfig === "function"
611+
? await services[keyName].__joinerConfig?.()
612+
: services[keyName].__joinerConfig
594613
} catch {
595614
// noop
596615
}

0 commit comments

Comments
 (0)