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:
co-authored by
Oli Juhl
Carlos R. L. Rodrigues
parent
4de555b546
commit
fe49b567d6
@@ -99,8 +99,7 @@
|
||||
"@aws-sdk/client-dynamodb": "^3.218.0",
|
||||
"@medusajs/cli": "2.12.1",
|
||||
"connect-dynamodb": "^3.0.5",
|
||||
"ioredis": "^5.4.1",
|
||||
"vite": "^5.4.21"
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/client-dynamodb": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,20 @@ class MedusaModule {
|
||||
return [...MedusaModule.moduleResolutions_.values()]
|
||||
}
|
||||
|
||||
public static unregisterModuleResolution(moduleKey: string): void {
|
||||
MedusaModule.moduleResolutions_.delete(moduleKey)
|
||||
MedusaModule.joinerConfig_.delete(moduleKey)
|
||||
const moduleAliases = MedusaModule.modules_
|
||||
.get(moduleKey)
|
||||
?.map((m) => m.alias || m.hash)
|
||||
if (moduleAliases) {
|
||||
for (const alias of moduleAliases) {
|
||||
MedusaModule.instances_.delete(alias)
|
||||
}
|
||||
}
|
||||
MedusaModule.modules_.delete(moduleKey)
|
||||
}
|
||||
|
||||
public static setModuleResolution(
|
||||
moduleKey: string,
|
||||
resolution: ModuleResolution
|
||||
@@ -516,25 +530,27 @@ class MedusaModule {
|
||||
}
|
||||
|
||||
const resolvedServices = await promiseAll(
|
||||
loadedModules.map(async ({
|
||||
hashKey,
|
||||
modDeclaration,
|
||||
moduleResolutions,
|
||||
container,
|
||||
finishLoading,
|
||||
}) => {
|
||||
const service = await MedusaModule.resolveLoadedModule({
|
||||
loadedModules.map(
|
||||
async ({
|
||||
hashKey,
|
||||
modDeclaration,
|
||||
moduleResolutions,
|
||||
container,
|
||||
})
|
||||
finishLoading,
|
||||
}) => {
|
||||
const service = await MedusaModule.resolveLoadedModule({
|
||||
hashKey,
|
||||
modDeclaration,
|
||||
moduleResolutions,
|
||||
container,
|
||||
})
|
||||
|
||||
MedusaModule.instances_.set(hashKey, service)
|
||||
finishLoading(service)
|
||||
MedusaModule.loading_.delete(hashKey)
|
||||
return service
|
||||
})
|
||||
MedusaModule.instances_.set(hashKey, service)
|
||||
finishLoading(service)
|
||||
MedusaModule.loading_.delete(hashKey)
|
||||
return service
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
services.push(...resolvedServices)
|
||||
@@ -590,7 +606,10 @@ class MedusaModule {
|
||||
|
||||
try {
|
||||
// TODO: rework that to store on a separate property
|
||||
joinerConfig = await services[keyName].__joinerConfig?.()
|
||||
joinerConfig =
|
||||
typeof services[keyName].__joinerConfig === "function"
|
||||
? await services[keyName].__joinerConfig?.()
|
||||
: services[keyName].__joinerConfig
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ export * as SearchUtils from "./search"
|
||||
export * as ShippingProfileUtils from "./shipping"
|
||||
export * as UserUtils from "./user"
|
||||
export * as CachingUtils from "./caching"
|
||||
export * as DevServerUtils from "./dev-server"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { JobResourceData, ResourceEntry, ResourceTypeHandler } from "../types"
|
||||
|
||||
export class JobHandler implements ResourceTypeHandler<JobResourceData> {
|
||||
readonly type = "job"
|
||||
|
||||
validate(data: JobResourceData): void {
|
||||
if (!data.id) {
|
||||
throw new Error(
|
||||
`Job registration requires id. Received: ${JSON.stringify(data)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.sourcePath) {
|
||||
throw new Error(
|
||||
`Job registration requires sourcePath. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.config?.name) {
|
||||
throw new Error(
|
||||
`Job registration requires config.name. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolveSourcePath(data: JobResourceData): string {
|
||||
return data.sourcePath
|
||||
}
|
||||
|
||||
createEntry(data: JobResourceData): ResourceEntry {
|
||||
return {
|
||||
id: data.id,
|
||||
config: data.config,
|
||||
}
|
||||
}
|
||||
|
||||
getInverseKey(data: JobResourceData): string {
|
||||
return `${this.type}:${data.config?.name}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ResourceEntry, ResourceTypeHandler, StepResourceData } from "../types"
|
||||
|
||||
export class StepHandler implements ResourceTypeHandler<StepResourceData> {
|
||||
readonly type = "step"
|
||||
|
||||
constructor(private inverseRegistry: Map<string, string[]>) {}
|
||||
|
||||
validate(data: StepResourceData): void {
|
||||
if (!data.id) {
|
||||
throw new Error(
|
||||
`Step registration requires id. Received: ${JSON.stringify(data)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.sourcePath && !data.workflowId) {
|
||||
throw new Error(
|
||||
`Step registration requires either sourcePath or workflowId. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolveSourcePath(data: StepResourceData): string {
|
||||
if (data.sourcePath) {
|
||||
return data.sourcePath
|
||||
}
|
||||
|
||||
// Look up workflow's source path
|
||||
const workflowKey = `workflow:${data.workflowId}`
|
||||
const workflowSourcePaths = this.inverseRegistry.get(workflowKey)
|
||||
|
||||
if (!workflowSourcePaths || workflowSourcePaths.length === 0) {
|
||||
throw new Error(
|
||||
`step workflow not found: ${data.workflowId} for step ${data.id}`
|
||||
)
|
||||
}
|
||||
|
||||
return workflowSourcePaths[0]
|
||||
}
|
||||
|
||||
createEntry(data: StepResourceData): ResourceEntry {
|
||||
return {
|
||||
id: data.id,
|
||||
workflowId: data.workflowId,
|
||||
}
|
||||
}
|
||||
|
||||
getInverseKey(data: StepResourceData): string {
|
||||
return `${this.type}:${data.id}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ResourceEntry,
|
||||
ResourceTypeHandler,
|
||||
SubscriberResourceData,
|
||||
} from "../types"
|
||||
|
||||
export class SubscriberHandler
|
||||
implements ResourceTypeHandler<SubscriberResourceData>
|
||||
{
|
||||
readonly type = "subscriber"
|
||||
|
||||
validate(data: SubscriberResourceData): void {
|
||||
if (!data.id) {
|
||||
throw new Error(
|
||||
`Subscriber registration requires id. Received: ${JSON.stringify(data)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.sourcePath) {
|
||||
throw new Error(
|
||||
`Subscriber registration requires sourcePath. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.subscriberId) {
|
||||
throw new Error(
|
||||
`Subscriber registration requires subscriberId. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.events) {
|
||||
throw new Error(
|
||||
`Subscriber registration requires events. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.events)) {
|
||||
throw new Error(
|
||||
`Subscriber registration requires events to be an array. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolveSourcePath(data: SubscriberResourceData): string {
|
||||
return data.sourcePath
|
||||
}
|
||||
|
||||
createEntry(data: SubscriberResourceData): ResourceEntry {
|
||||
return {
|
||||
id: data.id,
|
||||
subscriberId: data.subscriberId,
|
||||
events: data.events,
|
||||
}
|
||||
}
|
||||
|
||||
getInverseKey(data: SubscriberResourceData): string {
|
||||
return `${this.type}:${data.subscriberId}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
ResourceEntry,
|
||||
ResourceTypeHandler,
|
||||
WorkflowResourceData,
|
||||
} from "../types"
|
||||
|
||||
export class WorkflowHandler
|
||||
implements ResourceTypeHandler<WorkflowResourceData>
|
||||
{
|
||||
readonly type = "workflow"
|
||||
|
||||
validate(data: WorkflowResourceData): void {
|
||||
if (!data.sourcePath) {
|
||||
throw new Error(
|
||||
`Workflow registration requires sourcePath. Received: ${JSON.stringify(
|
||||
data
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.id) {
|
||||
throw new Error(
|
||||
`Workflow registration requires id. Received: ${JSON.stringify(data)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolveSourcePath(data: WorkflowResourceData): string {
|
||||
return data.sourcePath
|
||||
}
|
||||
|
||||
createEntry(data: WorkflowResourceData): ResourceEntry {
|
||||
return {
|
||||
id: data.id,
|
||||
workflowId: data.id,
|
||||
}
|
||||
}
|
||||
|
||||
getInverseKey(data: WorkflowResourceData): string {
|
||||
return `${this.type}:${data.id}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { FeatureFlag } from "../feature-flags"
|
||||
import { JobHandler } from "./handlers/job-handler"
|
||||
import { StepHandler } from "./handlers/step-handler"
|
||||
import { SubscriberHandler } from "./handlers/subscriber-handler"
|
||||
import { WorkflowHandler } from "./handlers/workflow-handler"
|
||||
import {
|
||||
addToInverseRegistry,
|
||||
addToRegistry,
|
||||
getOrCreateRegistry,
|
||||
} from "./registry-helpers"
|
||||
import {
|
||||
BaseResourceData,
|
||||
ResourceMap,
|
||||
ResourcePath,
|
||||
ResourceRegistrationData,
|
||||
ResourceTypeHandler,
|
||||
} from "./types"
|
||||
|
||||
export type {
|
||||
BaseResourceData,
|
||||
ResourceEntry,
|
||||
ResourceMap,
|
||||
ResourcePath,
|
||||
ResourceType,
|
||||
ResourceTypeHandler,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Maps source file paths to their registered resources
|
||||
* Structure: sourcePath -> Map<resourceType, ResourceEntry[]>
|
||||
*/
|
||||
export const globalDevServerRegistry = new Map<ResourcePath, ResourceMap>()
|
||||
|
||||
/**
|
||||
* Inverse registry for looking up source paths by resource
|
||||
* Structure: "type:id" -> sourcePath[]
|
||||
* Used to find which files contain a specific resource
|
||||
*/
|
||||
export const inverseDevServerRegistry = new Map<ResourcePath, ResourcePath[]>()
|
||||
|
||||
/**
|
||||
* Registry of resource type handlers
|
||||
* Each handler implements the logic for a specific resource type
|
||||
*/
|
||||
const resourceHandlers = new Map<string, ResourceTypeHandler>()
|
||||
|
||||
/**
|
||||
* Register a resource type handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class RouteHandler implements ResourceTypeHandler<RouteData> {
|
||||
* readonly type = "route"
|
||||
* validate(data: RouteData): void { ... }
|
||||
* resolveSourcePath(data: RouteData): string { ... }
|
||||
* createEntry(data: RouteData): ResourceEntry { ... }
|
||||
* getInverseKey(data: RouteData): string { ... }
|
||||
* }
|
||||
*
|
||||
* registerResourceTypeHandler(new RouteHandler())
|
||||
* ```
|
||||
*/
|
||||
export function registerResourceTypeHandler(
|
||||
handler: ResourceTypeHandler
|
||||
): void {
|
||||
if (resourceHandlers.has(handler.type)) {
|
||||
console.warn(
|
||||
`Resource type handler for "${handler.type}" is being overridden`
|
||||
)
|
||||
}
|
||||
|
||||
resourceHandlers.set(handler.type, handler)
|
||||
}
|
||||
|
||||
registerResourceTypeHandler(new WorkflowHandler())
|
||||
registerResourceTypeHandler(new StepHandler(inverseDevServerRegistry))
|
||||
registerResourceTypeHandler(new SubscriberHandler())
|
||||
registerResourceTypeHandler(new JobHandler())
|
||||
|
||||
/**
|
||||
* Register a resource in the dev server for hot module reloading
|
||||
*
|
||||
* This function uses a strategy pattern where each resource type has its own handler.
|
||||
* The handler is responsible for:
|
||||
* - Validating the registration data
|
||||
* - Resolving the source path
|
||||
* - Creating the registry entry
|
||||
* - Generating the inverse registry key
|
||||
*
|
||||
* @param data - Resource registration data
|
||||
* @throws Error if validation fails or handler is not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Register a workflow
|
||||
* registerDevServerResource({
|
||||
* type: "workflow",
|
||||
* id: "create-product",
|
||||
* sourcePath: "/src/workflows/create-product.ts"
|
||||
* })
|
||||
*
|
||||
* // Register a step
|
||||
* registerDevServerResource({
|
||||
* type: "step",
|
||||
* id: "validate-product",
|
||||
* workflowId: "create-product"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function registerDevServerResource(data: ResourceRegistrationData): void
|
||||
export function registerDevServerResource<T extends BaseResourceData>(
|
||||
data: T
|
||||
): void
|
||||
export function registerDevServerResource<T extends BaseResourceData>(
|
||||
data: T
|
||||
): void {
|
||||
// Skip registration in production or if HMR is disabled
|
||||
const isProduction = ["production", "prod"].includes(
|
||||
process.env.NODE_ENV || ""
|
||||
)
|
||||
|
||||
if (!FeatureFlag.isFeatureEnabled("backend_hmr") || isProduction) {
|
||||
return
|
||||
}
|
||||
|
||||
const handler = resourceHandlers.get(data.type)
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`No handler registered for resource type "${data.type}". ` +
|
||||
`Available types: ${Array.from(resourceHandlers.keys()).join(", ")}. ` +
|
||||
`Use registerResourceTypeHandler() to add support for custom types.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
handler.validate(data)
|
||||
|
||||
const sourcePath = handler.resolveSourcePath(data)
|
||||
|
||||
const registry = getOrCreateRegistry(globalDevServerRegistry, sourcePath)
|
||||
|
||||
const entry = handler.createEntry(data)
|
||||
addToRegistry(registry, data.type, entry)
|
||||
|
||||
const inverseKey = handler.getInverseKey(data)
|
||||
addToInverseRegistry(inverseDevServerRegistry, inverseKey, sourcePath)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to register ${data.type} resource "${data.id}": ${errorMessage}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ResourceEntry, ResourceMap, ResourcePath } from "./types"
|
||||
|
||||
export function getOrCreateRegistry(
|
||||
globalRegistry: Map<ResourcePath, ResourceMap>,
|
||||
sourcePath: string
|
||||
): ResourceMap {
|
||||
let registry = globalRegistry.get(sourcePath)
|
||||
|
||||
if (!registry) {
|
||||
registry = new Map<string, ResourceEntry[]>()
|
||||
globalRegistry.set(sourcePath, registry)
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
export function addToRegistry(
|
||||
registry: ResourceMap,
|
||||
type: string,
|
||||
entry: ResourceEntry
|
||||
): void {
|
||||
const entries = registry.get(type) || []
|
||||
registry.set(type, [...entries, entry])
|
||||
}
|
||||
|
||||
export function addToInverseRegistry(
|
||||
inverseRegistry: Map<string, string[]>,
|
||||
key: string,
|
||||
sourcePath: string
|
||||
): void {
|
||||
const existing = inverseRegistry.get(key) || []
|
||||
const updated = Array.from(new Set([...existing, sourcePath]))
|
||||
inverseRegistry.set(key, updated)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export type ResourcePath = string
|
||||
export type ResourceType = string
|
||||
export type ResourceEntry = {
|
||||
id: string
|
||||
workflowId?: string
|
||||
[key: string]: any
|
||||
}
|
||||
export type ResourceMap = Map<string, ResourceEntry[]>
|
||||
|
||||
export interface BaseResourceData {
|
||||
type: string
|
||||
id: string
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
export interface WorkflowResourceData extends BaseResourceData {
|
||||
type: "workflow"
|
||||
sourcePath: string
|
||||
}
|
||||
|
||||
export interface StepResourceData extends BaseResourceData {
|
||||
type: "step"
|
||||
workflowId?: string
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
export interface SubscriberResourceData extends BaseResourceData {
|
||||
type: "subscriber"
|
||||
sourcePath: string
|
||||
subscriberId: string
|
||||
events: string[]
|
||||
}
|
||||
|
||||
export interface JobResourceData extends BaseResourceData {
|
||||
type: "job"
|
||||
sourcePath: string
|
||||
config: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ResourceRegistrationData =
|
||||
| WorkflowResourceData
|
||||
| StepResourceData
|
||||
| SubscriberResourceData
|
||||
|
||||
export interface ResourceTypeHandler<
|
||||
T extends BaseResourceData = BaseResourceData
|
||||
> {
|
||||
readonly type: string
|
||||
|
||||
validate(data: T): void
|
||||
|
||||
resolveSourcePath(data: T): string
|
||||
|
||||
createEntry(data: T): ResourceEntry
|
||||
|
||||
getInverseKey(data: T): string
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export * from "./totals"
|
||||
export * from "./totals/big-number"
|
||||
export * from "./user"
|
||||
export * from "./caching"
|
||||
export * from "./dev-server"
|
||||
|
||||
export const MedusaModuleType = Symbol.for("MedusaModule")
|
||||
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
WorkflowStepHandler,
|
||||
WorkflowStepHandlerArguments,
|
||||
} from "@medusajs/orchestration"
|
||||
import { isDefined, isString, OrchestrationUtils } from "@medusajs/utils"
|
||||
import {
|
||||
getCallerFilePath,
|
||||
isDefined,
|
||||
isString,
|
||||
OrchestrationUtils,
|
||||
registerDevServerResource,
|
||||
} from "@medusajs/utils"
|
||||
import { ulid } from "ulid"
|
||||
import { resolveValue, StepResponse } from "./helpers"
|
||||
import { createStepHandler } from "./helpers/create-step-handler"
|
||||
@@ -159,6 +165,12 @@ export function applyStep<
|
||||
)
|
||||
}
|
||||
|
||||
registerDevServerResource({
|
||||
id: stepName,
|
||||
type: "step",
|
||||
workflowId: this.workflowId!,
|
||||
})
|
||||
|
||||
const handler = createAndConfigureHandler(
|
||||
this,
|
||||
stepName,
|
||||
@@ -490,5 +502,12 @@ export function createStep<
|
||||
returnFn.__type = OrchestrationUtils.SymbolWorkflowStepBind
|
||||
returnFn.__step__ = stepName
|
||||
|
||||
const sourcePath = getCallerFilePath() as string
|
||||
registerDevServerResource({
|
||||
id: stepName,
|
||||
type: "step",
|
||||
sourcePath,
|
||||
})
|
||||
|
||||
return returnFn
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isString,
|
||||
Modules,
|
||||
OrchestrationUtils,
|
||||
registerDevServerResource,
|
||||
} from "@medusajs/utils"
|
||||
import { ulid } from "ulid"
|
||||
import { exportWorkflow, WorkflowResult } from "../../helper"
|
||||
@@ -116,6 +117,12 @@ export function createWorkflow<TData, TResult, THooks extends any[]>(
|
||||
const name = isString(nameOrConfig) ? nameOrConfig : nameOrConfig.name
|
||||
const options = isString(nameOrConfig) ? {} : nameOrConfig
|
||||
|
||||
registerDevServerResource({
|
||||
sourcePath: fileSourcePath,
|
||||
id: name,
|
||||
type: "workflow",
|
||||
})
|
||||
|
||||
const handlers: WorkflowHandler = new Map()
|
||||
|
||||
let newWorkflow = false
|
||||
|
||||
Reference in New Issue
Block a user