chore(framework): Move and improve subscriber loader (#8347)
**What** Move `SubscriberLoader` and improve implementation FIXES FRMW-2635
This commit is contained in:
committed by
GitHub
parent
169953ad1e
commit
a9fea986b0
@@ -8,37 +8,11 @@
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"node": "./dist/index.js",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config/index.d.ts",
|
||||
"import": "./dist/config/index.js",
|
||||
"require": "./dist/config/index.js",
|
||||
"node": "./dist/config/index.js"
|
||||
},
|
||||
"./logger": {
|
||||
"types": "./dist/logger/index.d.ts",
|
||||
"import": "./dist/logger/index.js",
|
||||
"require": "./dist/logger/index.js",
|
||||
"node": "./dist/logger/index.js"
|
||||
},
|
||||
"./database": {
|
||||
"types": "./dist/database/index.d.ts",
|
||||
"import": "./dist/database/index.js",
|
||||
"require": "./dist/database/index.js",
|
||||
"node": "./dist/database/index.js"
|
||||
},
|
||||
"./feature-flag": {
|
||||
"types": "./dist/feature-flags/index.d.ts",
|
||||
"import": "./dist/feature-flags/index.js",
|
||||
"require": "./dist/feature-flags/index.js",
|
||||
"node": "./dist/feature-flags/index.js"
|
||||
}
|
||||
".": "./dist/index.js",
|
||||
"./config": "./dist/config/index.js",
|
||||
"./logger": "./dist/logger/index.js",
|
||||
"./database": "./dist/database/index.js",
|
||||
"./subscribers": "./dist/subscribers/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from "./logger"
|
||||
export * from "./http"
|
||||
export * from "./database"
|
||||
export * from "./container"
|
||||
export * from "./subscribers"
|
||||
export * from "./feature-flags"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SubscriberArgs, SubscriberConfig } from "../../types"
|
||||
|
||||
export default async function orderNotifier(_: SubscriberArgs) {
|
||||
return await Promise.resolve()
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: ["order.placed", "order.canceled", "order.completed"],
|
||||
context: { subscriberId: "order-notifier" },
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SubscriberArgs, SubscriberConfig } from "../../types"
|
||||
|
||||
export default async function productUpdater(_: SubscriberArgs) {
|
||||
return await Promise.resolve()
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "product.updated",
|
||||
context: {
|
||||
subscriberId: "product-updater",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SubscriberArgs, SubscriberConfig } from "../../types"
|
||||
|
||||
export default async function (_: SubscriberArgs) {
|
||||
return await Promise.resolve()
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "variant.created",
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const eventBusServiceMock = {
|
||||
subscribe: jest.fn().mockImplementation((...args) => {
|
||||
return Promise.resolve(args)
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { join } from "path"
|
||||
import { eventBusServiceMock } from "../__mocks__"
|
||||
import { SubscriberLoader } from "../subscriber-loader"
|
||||
import { container } from "../../container"
|
||||
import { ModuleRegistrationName } from "@medusajs/utils"
|
||||
import { asValue } from "awilix"
|
||||
|
||||
describe("SubscriberLoader", () => {
|
||||
const rootDir = join(__dirname, "../__fixtures__", "subscribers")
|
||||
|
||||
const pluginOptions = {
|
||||
important_data: {
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
let registeredPaths: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
container.register(
|
||||
ModuleRegistrationName.EVENT_BUS,
|
||||
asValue(eventBusServiceMock)
|
||||
)
|
||||
|
||||
const paths = await new SubscriberLoader(rootDir, pluginOptions).load()
|
||||
|
||||
if (paths) {
|
||||
registeredPaths = [...registeredPaths, ...paths]
|
||||
}
|
||||
})
|
||||
|
||||
it("should register each subscriber in the '/subscribers' folder", async () => {
|
||||
// As '/subscribers' contains 3 subscribers, we expect the number of registered paths to be 3
|
||||
expect(registeredPaths.length).toEqual(3)
|
||||
})
|
||||
|
||||
it("should have registered subscribers for 5 events", async () => {
|
||||
/**
|
||||
* The 'product-updater.ts' subscriber is registered for the following events:
|
||||
* - "product.created"
|
||||
* The 'order-updater.ts' subscriber is registered for the following events:
|
||||
* - "order.placed"
|
||||
* - "order.canceled"
|
||||
* - "order.completed"
|
||||
* The 'variant-created.ts' subscriber is registered for the following events:
|
||||
* - "variant.created"
|
||||
*
|
||||
* This means that we expect the eventBusServiceMock.subscribe method to have
|
||||
* been called times, once for 'product-updater.ts', once for 'variant-created.ts',
|
||||
* and 3 times for 'order-updater.ts'.
|
||||
*/
|
||||
expect(eventBusServiceMock.subscribe).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it("should have registered subscribers with the correct props", async () => {
|
||||
/**
|
||||
* The 'product-updater.ts' subscriber is registered
|
||||
* with a explicit subscriberId of "product-updater".
|
||||
*/
|
||||
expect(eventBusServiceMock.subscribe).toHaveBeenCalledWith(
|
||||
"product.updated",
|
||||
expect.any(Function),
|
||||
{
|
||||
subscriberId: "product-updater",
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The 'order-updater.ts' subscriber is registered
|
||||
* without an explicit subscriberId, which means that
|
||||
* the loader tries to infer one from either the handler
|
||||
* functions name or the file name. In this case, the
|
||||
* handler function is named 'orderUpdater' and is used
|
||||
* to infer the subscriberId.
|
||||
*/
|
||||
expect(eventBusServiceMock.subscribe).toHaveBeenCalledWith(
|
||||
"order.placed",
|
||||
expect.any(Function),
|
||||
{
|
||||
subscriberId: "order-notifier",
|
||||
}
|
||||
)
|
||||
|
||||
expect(eventBusServiceMock.subscribe).toHaveBeenCalledWith(
|
||||
"order.canceled",
|
||||
expect.any(Function),
|
||||
{
|
||||
subscriberId: "order-notifier",
|
||||
}
|
||||
)
|
||||
|
||||
expect(eventBusServiceMock.subscribe).toHaveBeenCalledWith(
|
||||
"order.completed",
|
||||
expect.any(Function),
|
||||
{
|
||||
subscriberId: "order-notifier",
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The 'variant-created.ts' subscriber is registered
|
||||
* without an explicit subscriberId, and with an anonymous
|
||||
* handler function. This means that the loader tries to
|
||||
* infer the subscriberId from the file name, which in this
|
||||
* case is 'variant-created.ts'.
|
||||
*/
|
||||
expect(eventBusServiceMock.subscribe).toHaveBeenCalledWith(
|
||||
"variant.created",
|
||||
expect.any(Function),
|
||||
{
|
||||
subscriberId: "variant-created",
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
2
packages/framework/framework/src/subscribers/index.ts
Normal file
2
packages/framework/framework/src/subscribers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./subscriber-loader"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Event, IEventBusModuleService, Subscriber } from "@medusajs/types"
|
||||
import { kebabCase, ModuleRegistrationName, promiseAll } from "@medusajs/utils"
|
||||
import { access, readdir } from "fs/promises"
|
||||
import { join, parse } from "path"
|
||||
|
||||
import { configManager } from "../config"
|
||||
import { container } from "../container"
|
||||
import { SubscriberArgs, SubscriberConfig } from "./types"
|
||||
import { logger } from "../logger"
|
||||
|
||||
type SubscriberHandler<T> = (args: SubscriberArgs<T>) => Promise<void>
|
||||
|
||||
type SubscriberModule<T> = {
|
||||
config: SubscriberConfig
|
||||
handler: SubscriberHandler<T>
|
||||
}
|
||||
|
||||
export class SubscriberLoader {
|
||||
/**
|
||||
* The options of the plugin from which the subscribers are being loaded
|
||||
* @private
|
||||
*/
|
||||
#pluginOptions: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* The base directory from which to scan for the subscribers
|
||||
* @private
|
||||
*/
|
||||
#sourceDir: string
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the subscriber scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/\.DS_Store/,
|
||||
/(\.ts\.map|\.js\.map|\.d\.ts|\.md)/,
|
||||
/^_[^/\\]*(\.[^/\\]+)?$/,
|
||||
]
|
||||
|
||||
/**
|
||||
* Map of subscribers descriptors to consume in the loader
|
||||
* @private
|
||||
*/
|
||||
#subscriberDescriptors: Map<string, SubscriberModule<any>> = new Map()
|
||||
|
||||
constructor(sourceDir: string, options: Record<string, unknown> = {}) {
|
||||
this.#sourceDir = sourceDir
|
||||
this.#pluginOptions = options
|
||||
}
|
||||
|
||||
private validateSubscriber(
|
||||
subscriber: any,
|
||||
path: string
|
||||
): subscriber is {
|
||||
default: SubscriberHandler<unknown>
|
||||
config: SubscriberConfig
|
||||
} {
|
||||
const handler = subscriber.default
|
||||
|
||||
if (!handler || typeof handler !== "function") {
|
||||
/**
|
||||
* If the handler is not a function, we can't use it
|
||||
*/
|
||||
logger.warn(`The subscriber in ${path} is not a function. skipped.`)
|
||||
return false
|
||||
}
|
||||
|
||||
const config = subscriber.config
|
||||
|
||||
if (!config) {
|
||||
/**
|
||||
* If the subscriber is missing a config, we can't use it
|
||||
*/
|
||||
logger.warn(`The subscriber in ${path} is missing a config. skipped.`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!config.event) {
|
||||
/**
|
||||
* If the subscriber is missing an event, we can't use it.
|
||||
* In production we throw an error, else we log a warning
|
||||
*/
|
||||
if (configManager.isProduction) {
|
||||
throw new Error(
|
||||
`The subscriber in ${path} is missing an event in the config.`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`The subscriber in ${path} is missing an event in the config. skipped.`
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const events = Array.isArray(config.event) ? config.event : [config.event]
|
||||
|
||||
if (events.some((e: unknown) => !(typeof e === "string"))) {
|
||||
/**
|
||||
* If the subscribers event is not a string or an array of strings, we can't use it
|
||||
*/
|
||||
logger.warn(
|
||||
`The subscriber in ${path} has an invalid event config. The event must be a string or an array of strings. skipped.`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async createDescriptor(absolutePath: string) {
|
||||
return await import(absolutePath).then((module_) => {
|
||||
const isValid = this.validateSubscriber(module_, absolutePath)
|
||||
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#subscriberDescriptors.set(absolutePath, {
|
||||
config: module_.config,
|
||||
handler: module_.default,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async createMap(dirPath: string) {
|
||||
const promises = await readdir(dirPath, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
}).then(async (entries) => {
|
||||
return entries.flatMap(async (entry) => {
|
||||
if (
|
||||
this.#excludes.length &&
|
||||
this.#excludes.some((exclude) => exclude.test(entry.name))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return await this.createMap(fullPath)
|
||||
}
|
||||
|
||||
return await this.createDescriptor(fullPath)
|
||||
})
|
||||
})
|
||||
|
||||
await promiseAll(promises)
|
||||
}
|
||||
|
||||
private inferIdentifier<T>(
|
||||
fileName: string,
|
||||
{ context }: SubscriberConfig,
|
||||
handler: SubscriberHandler<T>
|
||||
) {
|
||||
/**
|
||||
* If subscriberId is provided, use that
|
||||
*/
|
||||
if (context?.subscriberId) {
|
||||
return context.subscriberId
|
||||
}
|
||||
|
||||
const handlerName = handler.name
|
||||
|
||||
/**
|
||||
* If the handler is not anonymous, use the name
|
||||
*/
|
||||
if (handlerName && !handlerName.startsWith("_default")) {
|
||||
return kebabCase(handlerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the handler is anonymous, use the file name
|
||||
*/
|
||||
const idFromFile = parse(fileName).name
|
||||
return kebabCase(idFromFile)
|
||||
}
|
||||
|
||||
private createSubscriber<T = unknown>({
|
||||
fileName,
|
||||
config,
|
||||
handler,
|
||||
}: {
|
||||
fileName: string
|
||||
config: SubscriberConfig
|
||||
handler: SubscriberHandler<T>
|
||||
}) {
|
||||
const eventBusService: IEventBusModuleService = container.resolve(
|
||||
ModuleRegistrationName.EVENT_BUS
|
||||
)
|
||||
|
||||
const { event } = config
|
||||
|
||||
const events = Array.isArray(event) ? event : [event]
|
||||
|
||||
const subscriberId = this.inferIdentifier(fileName, config, handler)
|
||||
|
||||
for (const e of events) {
|
||||
const subscriber = async (data: T) => {
|
||||
return await handler({
|
||||
event: { name: e, ...data } as unknown as Event<T>,
|
||||
container,
|
||||
pluginOptions: this.#pluginOptions,
|
||||
})
|
||||
}
|
||||
|
||||
eventBusService.subscribe(e, subscriber as Subscriber, {
|
||||
...config.context,
|
||||
subscriberId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
let hasSubscriberDir = false
|
||||
|
||||
try {
|
||||
await access(this.#sourceDir)
|
||||
hasSubscriberDir = true
|
||||
} catch (err) {
|
||||
logger.debug(`No subscriber directory found in ${this.#sourceDir}`)
|
||||
}
|
||||
|
||||
if (!hasSubscriberDir) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.createMap(this.#sourceDir)
|
||||
|
||||
for (const [
|
||||
fileName,
|
||||
{ config, handler },
|
||||
] of this.#subscriberDescriptors.entries()) {
|
||||
this.createSubscriber({
|
||||
fileName,
|
||||
config,
|
||||
handler,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file paths of the registered subscribers, to prevent the
|
||||
* backwards compatible loader from trying to register them.
|
||||
*/
|
||||
return [...this.#subscriberDescriptors.keys()]
|
||||
}
|
||||
}
|
||||
16
packages/framework/framework/src/subscribers/types.ts
Normal file
16
packages/framework/framework/src/subscribers/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Event, MedusaContainer } from "@medusajs/types"
|
||||
|
||||
interface SubscriberContext extends Record<string, unknown> {
|
||||
subscriberId?: string
|
||||
}
|
||||
|
||||
export type SubscriberConfig = {
|
||||
event: string | string[]
|
||||
context?: SubscriberContext
|
||||
}
|
||||
|
||||
export type SubscriberArgs<T = unknown> = {
|
||||
event: Event<T>
|
||||
container: MedusaContainer
|
||||
pluginOptions: Record<string, unknown>
|
||||
}
|
||||
Reference in New Issue
Block a user