refactor(framework): Align configuration and fixes (#9246)
* refactor(framework): Align configuration and fixes * refactor(framework): Align configuration and fixes * move framework * rm unnecessary script * update jest config
This commit is contained in:
committed by
GitHub
parent
9f72fb5902
commit
94e07c8da0
0
packages/core/framework/README.md
Normal file
0
packages/core/framework/README.md
Normal file
24
packages/core/framework/jest.config.js
Normal file
24
packages/core/framework/jest.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
transform: {
|
||||
"^.+\\.[jt]s$": [
|
||||
"@swc/jest",
|
||||
{
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
useDefineForClassFields: false,
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
target: "ES2021",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
testPathIgnorePatterns: [`dist/`, `node_modules/`],
|
||||
testEnvironment: `node`,
|
||||
moduleFileExtensions: [`js`, `ts`],
|
||||
}
|
||||
75
packages/core/framework/package.json
Normal file
75
packages/core/framework/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@medusajs/framework",
|
||||
"version": "0.0.1",
|
||||
"description": "Framework",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/__mocks__",
|
||||
"!dist/**/__fixtures__"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./config": "./dist/config/index.js",
|
||||
"./logger": "./dist/logger/index.js",
|
||||
"./database": "./dist/database/index.js",
|
||||
"./subscribers": "./dist/subscribers/index.js",
|
||||
"./workflows": "./dist/workflows/index.js",
|
||||
"./links": "./dist/links/index.js",
|
||||
"./jobs": "./dist/jobs/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/framework/framework"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"watch": "tsc --watch ",
|
||||
"watch:test": "tsc --watch",
|
||||
"build": "rimraf dist && tsc --build",
|
||||
"test": "jest --runInBand --bail --passWithNoTests --forceExit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/orchestration": "^0.5.7",
|
||||
"@medusajs/types": "^1.11.16",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"cross-env": "^7.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^4.0.2",
|
||||
"tsc-alias": "^1.8.6",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/medusa-cli": "^1.3.22",
|
||||
"@medusajs/modules-sdk": "^1.12.11",
|
||||
"@medusajs/utils": "^1.11.9",
|
||||
"@medusajs/workflows-sdk": "^0.1.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"ioredis-mock": "8.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"medusa-telemetry": "^0.0.17",
|
||||
"morgan": "^1.9.1",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"awilix": "^8.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "@medusajs/utils"
|
||||
|
||||
export default defineConfig({
|
||||
projectConfig: {
|
||||
databaseName: "foo",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "@medusajs/utils"
|
||||
|
||||
export default defineConfig()
|
||||
41
packages/core/framework/src/config/__tests__/index.spec.ts
Normal file
41
packages/core/framework/src/config/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { configLoader } from "../loader"
|
||||
import { join } from "path"
|
||||
import { container } from "../../container"
|
||||
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
||||
|
||||
describe("configLoader", () => {
|
||||
const entryDirectory = join(__dirname, "../__fixtures__")
|
||||
|
||||
it("should load the config properly", async () => {
|
||||
let configModule = container.resolve(
|
||||
ContainerRegistrationKeys.CONFIG_MODULE
|
||||
)
|
||||
|
||||
expect(configModule).toBeUndefined()
|
||||
|
||||
configLoader(entryDirectory, "medusa-config.js")
|
||||
|
||||
configModule = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
||||
|
||||
expect(configModule).toBeDefined()
|
||||
expect(configModule.projectConfig.databaseName).toBeUndefined()
|
||||
|
||||
configLoader(entryDirectory, "medusa-config-2.js")
|
||||
|
||||
configModule = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
||||
|
||||
expect(configModule).toBeDefined()
|
||||
expect(configModule.projectConfig.databaseName).toBe("foo")
|
||||
expect(configModule.projectConfig.workerMode).toBe("shared")
|
||||
|
||||
process.env.MEDUSA_WORKER_MODE = "worker"
|
||||
|
||||
configLoader(entryDirectory, "medusa-config-2.js")
|
||||
|
||||
configModule = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
|
||||
|
||||
expect(configModule).toBeDefined()
|
||||
expect(configModule.projectConfig.databaseName).toBe("foo")
|
||||
expect(configModule.projectConfig.workerMode).toBe("worker")
|
||||
})
|
||||
})
|
||||
175
packages/core/framework/src/config/config.ts
Normal file
175
packages/core/framework/src/config/config.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { ConfigModule } from "./types"
|
||||
import { deepCopy, isDefined } from "@medusajs/utils"
|
||||
import { logger } from "../logger"
|
||||
|
||||
export class ConfigManager {
|
||||
/**
|
||||
* Root dir from where to start
|
||||
* @private
|
||||
*/
|
||||
#baseDir: string
|
||||
|
||||
/**
|
||||
* A flag to specify if we are in production or not, determine whether an error would be critical and thrown or just logged as a warning in developement
|
||||
* @private
|
||||
*/
|
||||
get #isProduction(): boolean {
|
||||
return ["production", "prod"].includes(process.env.NODE_ENV || "")
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker mode
|
||||
* @private
|
||||
*/
|
||||
get #envWorkMode(): ConfigModule["projectConfig"]["workerMode"] {
|
||||
return process.env
|
||||
.MEDUSA_WORKER_MODE as ConfigModule["projectConfig"]["workerMode"]
|
||||
}
|
||||
|
||||
/**
|
||||
* The config object after loading it
|
||||
* @private
|
||||
*/
|
||||
#config!: ConfigModule
|
||||
|
||||
get config(): ConfigModule {
|
||||
if (!this.#config) {
|
||||
this.rejectErrors(
|
||||
`Config not loaded. Make sure the config have been loaded first using the 'configLoader' or 'configManager.loadConfig'.`
|
||||
)
|
||||
}
|
||||
return this.#config
|
||||
}
|
||||
|
||||
get baseDir(): string {
|
||||
return this.#baseDir
|
||||
}
|
||||
|
||||
get isProduction(): boolean {
|
||||
return this.#isProduction
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Rejects an error either by throwing when in production or by logging the error as a warning
|
||||
* @param error
|
||||
* @protected
|
||||
*/
|
||||
protected rejectErrors(error: string): never | void {
|
||||
if (this.#isProduction) {
|
||||
throw new Error(`[config] ⚠️ ${error}`)
|
||||
}
|
||||
|
||||
logger.warn(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the http config object and assign the defaults if needed
|
||||
* @param projectConfig
|
||||
* @protected
|
||||
*/
|
||||
protected buildHttpConfig(
|
||||
projectConfig: Partial<ConfigModule["projectConfig"]>
|
||||
): ConfigModule["projectConfig"]["http"] {
|
||||
const http = (projectConfig.http ??
|
||||
{}) as ConfigModule["projectConfig"]["http"]
|
||||
|
||||
http.jwtExpiresIn = http?.jwtExpiresIn ?? "1d"
|
||||
http.authCors = http.authCors ?? ""
|
||||
http.storeCors = http.storeCors ?? ""
|
||||
http.adminCors = http.adminCors ?? ""
|
||||
|
||||
http.jwtSecret = http?.jwtSecret ?? process.env.JWT_SECRET
|
||||
|
||||
if (!http.jwtSecret) {
|
||||
this.rejectErrors(
|
||||
`http.jwtSecret not found.${
|
||||
this.#isProduction ? "" : "Using default 'supersecret'."
|
||||
}`
|
||||
)
|
||||
|
||||
http.jwtSecret = "supersecret"
|
||||
}
|
||||
|
||||
http.cookieSecret = (projectConfig.http?.cookieSecret ??
|
||||
process.env.COOKIE_SECRET)!
|
||||
|
||||
if (!http.cookieSecret) {
|
||||
this.rejectErrors(
|
||||
`http.cookieSecret not found.${
|
||||
this.#isProduction ? "" : " Using default 'supersecret'."
|
||||
}`
|
||||
)
|
||||
|
||||
http.cookieSecret = "supersecret"
|
||||
}
|
||||
|
||||
return http
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the project config object and assign the defaults if needed
|
||||
* @param projectConfig
|
||||
* @protected
|
||||
*/
|
||||
protected normalizeProjectConfig(
|
||||
projectConfig: Partial<ConfigModule["projectConfig"]>
|
||||
): ConfigModule["projectConfig"] {
|
||||
const outputConfig = deepCopy(
|
||||
projectConfig
|
||||
) as ConfigModule["projectConfig"]
|
||||
|
||||
if (!outputConfig?.redisUrl) {
|
||||
console.log(`redisUrl not found. A fake redis instance will be used.`)
|
||||
}
|
||||
|
||||
outputConfig.http = this.buildHttpConfig(projectConfig)
|
||||
|
||||
let workerMode = outputConfig?.workerMode!
|
||||
|
||||
if (!isDefined(workerMode)) {
|
||||
const env = this.#envWorkMode
|
||||
if (isDefined(env)) {
|
||||
const workerModes = ["shared", "worker", "server"]
|
||||
if (workerModes.includes(env)) {
|
||||
workerMode = env
|
||||
}
|
||||
} else {
|
||||
workerMode = "shared"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...outputConfig,
|
||||
workerMode,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the full configuration after validation and normalization
|
||||
*/
|
||||
loadConfig({
|
||||
projectConfig = {},
|
||||
baseDir,
|
||||
}: {
|
||||
projectConfig: Partial<ConfigModule>
|
||||
baseDir: string
|
||||
}): ConfigModule {
|
||||
this.#baseDir = baseDir
|
||||
|
||||
const normalizedProjectConfig = this.normalizeProjectConfig(
|
||||
projectConfig.projectConfig ?? {}
|
||||
)
|
||||
|
||||
this.#config = {
|
||||
projectConfig: normalizedProjectConfig,
|
||||
admin: projectConfig.admin ?? {},
|
||||
modules: projectConfig.modules ?? {},
|
||||
featureFlags: projectConfig.featureFlags ?? {},
|
||||
plugins: projectConfig.plugins ?? [],
|
||||
}
|
||||
|
||||
return this.#config
|
||||
}
|
||||
}
|
||||
3
packages/core/framework/src/config/index.ts
Normal file
3
packages/core/framework/src/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./loader"
|
||||
export * from "./config"
|
||||
export * from "./types"
|
||||
46
packages/core/framework/src/config/loader.ts
Normal file
46
packages/core/framework/src/config/loader.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ConfigModule } from "./types"
|
||||
import { ContainerRegistrationKeys, getConfigFile } from "@medusajs/utils"
|
||||
import { logger } from "../logger"
|
||||
import { ConfigManager } from "./config"
|
||||
import { container } from "../container"
|
||||
import { asFunction } from "awilix"
|
||||
|
||||
const handleConfigError = (error: Error): void => {
|
||||
logger.error(`Error in loading config: ${error.message}`)
|
||||
if (error.stack) {
|
||||
logger.error(error.stack)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const configManager = new ConfigManager()
|
||||
|
||||
container.register(
|
||||
ContainerRegistrationKeys.CONFIG_MODULE,
|
||||
asFunction(() => configManager.config)
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads the config file and returns the config module after validating, normalizing the configurations
|
||||
*
|
||||
* @param entryDirectory The directory to find the config file from
|
||||
* @param configFileName The name of the config file to search for in the entry directory
|
||||
*/
|
||||
export function configLoader(
|
||||
entryDirectory: string,
|
||||
configFileName: string
|
||||
): ConfigModule {
|
||||
const { configModule, error } = getConfigFile<ConfigModule>(
|
||||
entryDirectory,
|
||||
configFileName
|
||||
)
|
||||
|
||||
if (error) {
|
||||
handleConfigError(error)
|
||||
}
|
||||
|
||||
return configManager.loadConfig({
|
||||
projectConfig: configModule,
|
||||
baseDir: entryDirectory,
|
||||
})
|
||||
}
|
||||
937
packages/core/framework/src/config/types.ts
Normal file
937
packages/core/framework/src/config/types.ts
Normal file
@@ -0,0 +1,937 @@
|
||||
import {
|
||||
ExternalModuleDeclaration,
|
||||
InternalModuleDeclaration,
|
||||
} from "@medusajs/types"
|
||||
|
||||
import type { RedisOptions } from "ioredis"
|
||||
import type { InlineConfig } from "vite"
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* Admin dashboard configurations.
|
||||
*/
|
||||
export type AdminOptions = {
|
||||
/**
|
||||
* Whether to disable the admin dashboard. If set to `true`, the admin dashboard is disabled,
|
||||
* in both development and production environments. The default value is `false`.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* admin: {
|
||||
* disable: process.env.ADMIN_DISABLED === "true" ||
|
||||
* false
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
disable?: boolean
|
||||
/**
|
||||
* The path to the admin dashboard. The default value is `/app`.
|
||||
*
|
||||
* The value cannot be one of the reserved paths:
|
||||
* - `/admin`
|
||||
* - `/store`
|
||||
* - `/auth`
|
||||
* - `/`
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
* When using Docker, make sure that the root path of the Docker image doesn't path the admin's `path`. For example, if the Docker image's root path is `/app`, change
|
||||
* the value of the `path` configuration, as it's `/app` by default.
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* admin: {
|
||||
* path: process.env.ADMIN_PATH || `/app`,
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
path?: `/${string}`
|
||||
/**
|
||||
* The directory where the admin build is outputted when you run the `build` command.
|
||||
* The default value is `./build`.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* admin: {
|
||||
* outDir: process.env.ADMIN_BUILD_DIR || `./build`,
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
outDir?: string
|
||||
/**
|
||||
* The URL of your Medusa application. This is useful to set when you deploy the Medusa application.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* admin: {
|
||||
* backendUrl: process.env.MEDUSA_BACKEND_URL ||
|
||||
* "http://localhost:9000"
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
backendUrl?: string
|
||||
/**
|
||||
* Configure the Vite configuration for the admin dashboard. This function receives the default Vite configuration
|
||||
* and returns the modified configuration. The default value is `undefined`.
|
||||
*
|
||||
* @privateRemarks TODO Add example
|
||||
*/
|
||||
vite?: (config: InlineConfig) => InlineConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* Options to pass to `express-session`.
|
||||
*/
|
||||
type SessionOptions = {
|
||||
/**
|
||||
* The name of the session ID cookie to set in the response (and read from in the request). The default value is `connect.sid`.
|
||||
* Refer to [express-session’s documentation](https://www.npmjs.com/package/express-session#name) for more details.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Whether the session should be saved back to the session store, even if the session was never modified during the request. The default value is `true`.
|
||||
* Refer to [express-session’s documentation](https://www.npmjs.com/package/express-session#resave) for more details.
|
||||
*/
|
||||
resave?: boolean
|
||||
/**
|
||||
* Whether the session identifier cookie should be force-set on every response. The default value is `false`.
|
||||
* Refer to [express-session’s documentation](https://www.npmjs.com/package/express-session#rolling) for more details.
|
||||
*/
|
||||
rolling?: boolean
|
||||
/**
|
||||
* Whether a session that is "uninitialized" is forced to be saved to the store. The default value is `true`.
|
||||
* Refer to [express-session’s documentation](https://www.npmjs.com/package/express-session#saveUninitialized) for more details.
|
||||
*/
|
||||
saveUninitialized?: boolean
|
||||
/**
|
||||
* The secret to sign the session ID cookie. By default, the value of `http.cookieSecret` is used.
|
||||
* Refer to [express-session’s documentation](https://www.npmjs.com/package/express-session#secret) for details.
|
||||
*/
|
||||
secret?: string
|
||||
/**
|
||||
* Used when calculating the `Expires` `Set-Cookie` attribute of cookies. By default, its value is `10 * 60 * 60 * 1000`.
|
||||
* Refer to [express-session’s documentation](https://www.npmjs.com/package/express-session#cookiemaxage) for details.
|
||||
*/
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* HTTP compression configurations.
|
||||
*/
|
||||
export type HttpCompressionOptions = {
|
||||
/**
|
||||
* Whether HTTP compression is enabled. By default, it's `false`.
|
||||
*/
|
||||
enabled?: boolean
|
||||
/**
|
||||
* The level of zlib compression to apply to responses. A higher level will result in better compression but will take longer to complete.
|
||||
* A lower level will result in less compression but will be much faster. The default value is `6`.
|
||||
*/
|
||||
level?: number
|
||||
/**
|
||||
* How much memory should be allocated to the internal compression state. It's an integer in the range of 1 (minimum level) and 9 (maximum level).
|
||||
* The default value is `8`.
|
||||
*/
|
||||
memLevel?: number
|
||||
/**
|
||||
* The minimum response body size that compression is applied on. Its value can be the number of bytes or any string accepted by the
|
||||
* [bytes](https://www.npmjs.com/package/bytes) module. The default value is `1024`.
|
||||
*/
|
||||
threshold?: number | string
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* Essential configurations related to the Medusa application, such as database and CORS configurations.
|
||||
*/
|
||||
export type ProjectConfigOptions = {
|
||||
/**
|
||||
* The name of the database to connect to. If the name is specified in `databaseUrl`, then you don't have to use this configuration.
|
||||
*
|
||||
* Make sure to create the PostgreSQL database before using it. You can check how to create a database in
|
||||
* [PostgreSQL's documentation](https://www.postgresql.org/docs/current/sql-createdatabase.html).
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* databaseName: process.env.DATABASE_NAME ||
|
||||
* "medusa-store",
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
databaseName?: string
|
||||
|
||||
/**
|
||||
* The PostgreSQL connection URL of the database, which is of the following format:
|
||||
*
|
||||
* ```bash
|
||||
* postgres://[user][:password]@[host][:port]/[dbname]
|
||||
* ```
|
||||
*
|
||||
* Where:
|
||||
*
|
||||
* - `[user]`: (required) your PostgreSQL username. If not specified, the system's username is used by default. The database user that you use must have create privileges. If you're using the `postgres` superuser, then it should have these privileges by default. Otherwise, make sure to grant your user create privileges. You can learn how to do that in [PostgreSQL's documentation](https://www.postgresql.org/docs/current/ddl-priv.html).
|
||||
* - `[:password]`: an optional password for the user. When provided, make sure to put `:` before the password.
|
||||
* - `[host]`: (required) your PostgreSQL host. When run locally, it should be `localhost`.
|
||||
* - `[:port]`: an optional port that the PostgreSQL server is listening on. By default, it's `5432`. When provided, make sure to put `:` before the port.
|
||||
* - `[dbname]`: (required) the name of the database.
|
||||
*
|
||||
* You can learn more about the connection URL format in [PostgreSQL’s documentation](https://www.postgresql.org/docs/current/libpq-connect.html).
|
||||
*
|
||||
* @example
|
||||
* For example, set the following database URL in your environment variables:
|
||||
*
|
||||
* ```bash
|
||||
* DATABASE_URL=postgres://postgres@localhost/medusa-store
|
||||
* ```
|
||||
*
|
||||
* Then, use the value in `medusa-config.js`:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* databaseUrl: process.env.DATABASE_URL,
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
databaseUrl?: string
|
||||
|
||||
/**
|
||||
* The database schema to connect to. This is not required to provide if you’re using the default schema, which is `public`.
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* databaseSchema: process.env.DATABASE_SCHEMA ||
|
||||
* "custom",
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
databaseSchema?: string
|
||||
|
||||
/**
|
||||
* This configuration specifies whether database messages should be logged.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* databaseLogging: false
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
databaseLogging?: boolean
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* @deprecated
|
||||
*
|
||||
* @privateRemarks
|
||||
* only postgres is supported, so this config has no effect
|
||||
*/
|
||||
databaseType?: string
|
||||
|
||||
/**
|
||||
* This configuration is used to pass additional options to the database connection. You can pass any configuration. For example, pass the
|
||||
* `ssl` property that enables support for TLS/SSL connections.
|
||||
*
|
||||
* This is useful for production databases, which can be supported by setting the `rejectUnauthorized` attribute of `ssl` object to `false`.
|
||||
* During development, it’s recommended not to pass this option.
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
* Make sure to add to the end of the database URL `?ssl_mode=disable` as well when disabling `rejectUnauthorized`.
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* databaseDriverOptions: process.env.NODE_ENV !== "development" ?
|
||||
* { ssl: { rejectUnauthorized: false } } : {}
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
databaseDriverOptions?: Record<string, unknown> & {
|
||||
connection?: {
|
||||
/**
|
||||
* Configure support for TLS/SSL connection
|
||||
*/
|
||||
ssl?: {
|
||||
/**
|
||||
* Whether to fail connection if the server certificate is verified against the list of supplied CAs and the hostname and no match is found.
|
||||
*/
|
||||
rejectUnauthorized?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This configuration specifies the connection URL to Redis to store the Medusa server's session.
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
* You must first have Redis installed. You can refer to [Redis's installation guide](https://redis.io/docs/getting-started/installation/).
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* The Redis connection URL has the following format:
|
||||
*
|
||||
* ```bash
|
||||
* redis[s]://[[username][:password]@][host][:port][/db-number]
|
||||
* ```
|
||||
*
|
||||
* For a local Redis installation, the connection URL should be `redis://localhost:6379` unless you’ve made any changes to the Redis configuration during installation.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* redisUrl: process.env.REDIS_URL ||
|
||||
* "redis://localhost:6379",
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
redisUrl?: string
|
||||
|
||||
/**
|
||||
* This configuration defines a prefix on all keys stored in Redis for the Medusa server's session. The default value is `sess:`.
|
||||
*
|
||||
* If this configuration option is provided, it is prepended to `sess:`.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* redisPrefix: process.env.REDIS_URL || "medusa:",
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
redisPrefix?: string
|
||||
|
||||
/**
|
||||
* This configuration defines options to pass ioredis for the Redis connection used to store the Medusa server's session. Refer to [ioredis’s RedisOptions documentation](https://redis.github.io/ioredis/index.html#RedisOptions)
|
||||
* for the list of available options.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* redisOptions: {
|
||||
* connectionName: process.env.REDIS_CONNECTION_NAME ||
|
||||
* "medusa",
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
redisOptions?: RedisOptions
|
||||
|
||||
/**
|
||||
* This configuration defines additional options to pass to [express-session](https://www.npmjs.com/package/express-session), which is used to store the Medusa server's session.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* sessionOptions: {
|
||||
* name: process.env.SESSION_NAME || "custom",
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
sessionOptions?: SessionOptions
|
||||
|
||||
/**
|
||||
* This property configures the HTTP compression from the application layer. If you have access to the HTTP server, the recommended approach would be to enable it there.
|
||||
* However, some platforms don't offer access to the HTTP layer and in those cases, this is a good alternative.
|
||||
*
|
||||
* If you enable HTTP compression and you want to disable it for specific API Routes, you can pass in the request header `"x-no-compression": true`.
|
||||
*
|
||||
* @ignore
|
||||
*
|
||||
* @deprecated use {@link http }'s `compression` property instead.
|
||||
*
|
||||
*/
|
||||
httpCompression?: HttpCompressionOptions
|
||||
|
||||
/**
|
||||
* Configure the number of staged jobs that are polled from the database. Default is `1000`.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* jobsBatchSize: 100
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*
|
||||
* @privateRemarks
|
||||
* Couldn't find any use for this option.
|
||||
*/
|
||||
jobsBatchSize?: number
|
||||
|
||||
/**
|
||||
* Configure the application's worker mode.
|
||||
*
|
||||
* Workers are processes running separately from the main application. They're useful for executing long-running or resource-heavy tasks in the background, such as importing products.
|
||||
*
|
||||
* With a worker, these tasks are offloaded to a separate process. So, they won't affect the performance of the main application.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* Medusa has three runtime modes:
|
||||
*
|
||||
* - Use `shared` to run the application in a single process.
|
||||
* - Use `worker` to run the a worker process only.
|
||||
* - Use `server` to run the application server only.
|
||||
*
|
||||
* In production, it's recommended to deploy two instances:
|
||||
*
|
||||
* 1. One having the `workerMode` configuration set to `server`.
|
||||
* 2. Another having the `workerMode` configuration set to `worker`.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* workerMode: process.env.WORKER_MODE || "shared"
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
workerMode?: "shared" | "worker" | "server"
|
||||
|
||||
/**
|
||||
* This property configures the application's http-specific settings.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* cookieSecret: "supersecret",
|
||||
* compression: {
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
http: {
|
||||
/**
|
||||
* A random string used to create authentication tokens in the http layer. Although this configuration option is not required, it’s highly recommended to set it for better security.
|
||||
*
|
||||
* In a development environment, if this option is not set the default secret is `supersecret`. However, in production, if this configuration is not set, an
|
||||
* error is thrown and the application crashes.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* jwtSecret: "supersecret",
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
jwtSecret?: string
|
||||
/**
|
||||
* The expiration time for the JWT token. Its format is based off the [ms package](https://github.com/vercel/ms).
|
||||
*
|
||||
* If not provided, the default value is `24h`.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* jwtExpiresIn: "2d"
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
jwtExpiresIn?: string
|
||||
/**
|
||||
* A random string used to create cookie tokens in the http layer. Although this configuration option is not required, it’s highly recommended to set it for better security.
|
||||
*
|
||||
* In a development environment, if this option is not set, the default secret is `supersecret`. However, in production, if this configuration is not set, an error is thrown and
|
||||
* the application crashes.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* cookieSecret: "supersecret"
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
cookieSecret?: string
|
||||
/**
|
||||
* The Medusa application's API Routes are protected by Cross-Origin Resource Sharing (CORS). So, only allowed URLs or URLs matching a specified pattern can send requests to the backend’s API Routes.
|
||||
*
|
||||
* `cors` is a string used to specify the accepted URLs or patterns for API Routes starting with `/auth`. It can either be one accepted origin, or a comma-separated list of accepted origins.
|
||||
*
|
||||
* Every origin in that list must either be:
|
||||
*
|
||||
* 1. A URL. For example, `http://localhost:7001`. The URL must not end with a backslash;
|
||||
* 2. Or a regular expression pattern that can match more than one origin. For example, `.example.com`. The regex pattern that Medusa tests for is `^([\/~@;%#'])(.*?)\1([gimsuy]*)$`.
|
||||
*
|
||||
* @example
|
||||
* Some example values of common use cases:
|
||||
*
|
||||
* ```bash
|
||||
* # Allow different ports locally starting with 700
|
||||
* AUTH_CORS=/http:\/\/localhost:700\d+$/
|
||||
*
|
||||
* # Allow any origin ending with vercel.app. For example, admin.vercel.app
|
||||
* AUTH_CORS=/vercel\.app$/
|
||||
*
|
||||
* # Allow all HTTP requests
|
||||
* AUTH_CORS=/http:\/\/.+/
|
||||
* ```
|
||||
*
|
||||
* Then, set the configuration in `medusa-config.js`:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* authCors: process.env.AUTH_CORS
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* If you’re adding the value directly within `medusa-config.js`, make sure to add an extra escaping `/` for every backslash in the pattern. For example:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* authCors: "/http:\\/\\/localhost:700\\d+$/",
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
authCors: string
|
||||
/**
|
||||
*
|
||||
* Configure HTTP compression from the application layer. If you have access to the HTTP server, the recommended approach would be to enable it there.
|
||||
* However, some platforms don't offer access to the HTTP layer and in those cases, this is a good alternative.
|
||||
*
|
||||
* If you enable HTTP compression and you want to disable it for specific API Routes, you can pass in the request header `"x-no-compression": true`.
|
||||
* Learn more in the [API Reference](https://docs.medusajs.com/v2/api/store#http-compression).
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* compression: {
|
||||
* enabled: true,
|
||||
* level: 6,
|
||||
* memLevel: 8,
|
||||
* threshold: 1024
|
||||
* }
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
compression?: HttpCompressionOptions
|
||||
/**
|
||||
* The Medusa application's API Routes are protected by Cross-Origin Resource Sharing (CORS). So, only allowed URLs or URLs matching a specified pattern can send requests to the backend’s API Routes.
|
||||
*
|
||||
* `store_cors` is a string used to specify the accepted URLs or patterns for store API Routes. It can either be one accepted origin, or a comma-separated list of accepted origins.
|
||||
*
|
||||
* Every origin in that list must either be:
|
||||
*
|
||||
* 1. A URL. For example, `http://localhost:8000`. The URL must not end with a backslash;
|
||||
* 2. Or a regular expression pattern that can match more than one origin. For example, `.example.com`. The regex pattern that the backend tests for is `^([\/~@;%#'])(.*?)\1([gimsuy]*)$`.
|
||||
*
|
||||
* @example
|
||||
* Some example values of common use cases:
|
||||
*
|
||||
* ```bash
|
||||
* # Allow different ports locally starting with 800
|
||||
* STORE_CORS=/http:\/\/localhost:800\d+$/
|
||||
*
|
||||
* # Allow any origin ending with vercel.app. For example, storefront.vercel.app
|
||||
* STORE_CORS=/vercel\.app$/
|
||||
*
|
||||
* # Allow all HTTP requests
|
||||
* STORE_CORS=/http:\/\/.+/
|
||||
* ```
|
||||
*
|
||||
* Then, set the configuration in `medusa-config.js`:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* storeCors: process.env.STORE_CORS,
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* If you’re adding the value directly within `medusa-config.js`, make sure to add an extra escaping `/` for every backslash in the pattern. For example:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* storeCors: "/vercel\\.app$/",
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
storeCors: string
|
||||
|
||||
/**
|
||||
* The Medusa application's API Routes are protected by Cross-Origin Resource Sharing (CORS). So, only allowed URLs or URLs matching a specified pattern can send requests to the backend’s API Routes.
|
||||
*
|
||||
* `admin_cors` is a string used to specify the accepted URLs or patterns for admin API Routes. It can either be one accepted origin, or a comma-separated list of accepted origins.
|
||||
*
|
||||
* Every origin in that list must either be:
|
||||
*
|
||||
* 1. A URL. For example, `http://localhost:7001`. The URL must not end with a backslash;
|
||||
* 2. Or a regular expression pattern that can match more than one origin. For example, `.example.com`. The regex pattern that the backend tests for is `^([\/~@;%#'])(.*?)\1([gimsuy]*)$`.
|
||||
*
|
||||
* @example
|
||||
* Some example values of common use cases:
|
||||
*
|
||||
* ```bash
|
||||
* # Allow different ports locally starting with 700
|
||||
* ADMIN_CORS=/http:\/\/localhost:700\d+$/
|
||||
*
|
||||
* # Allow any origin ending with vercel.app. For example, admin.vercel.app
|
||||
* ADMIN_CORS=/vercel\.app$/
|
||||
*
|
||||
* # Allow all HTTP requests
|
||||
* ADMIN_CORS=/http:\/\/.+/
|
||||
* ```
|
||||
*
|
||||
* Then, set the configuration in `medusa-config.js`:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* adminCors: process.env.ADMIN_CORS,
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* If you’re adding the value directly within `medusa-config.js`, make sure to add an extra escaping `/` for every backslash in the pattern. For example:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* adminCors: "/vercel\\.app$/",
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
adminCors: string
|
||||
|
||||
/**
|
||||
* This configuration specifies the supported authentication providers per actor type (such as `user`, `customer`, or any custom actors).
|
||||
* For example, you only want to allow SSO logins for `users`, while you want to allow email/password logins for `customers` to the storefront.
|
||||
*
|
||||
* `authMethodsPerActor` is a a map where the actor type (eg. 'user') is the key, and the value is an array of supported auth provider IDs.
|
||||
*
|
||||
* @example
|
||||
* Some example values of common use cases:
|
||||
*
|
||||
* Then, set the configuration in `medusa-config.js`:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* http: {
|
||||
* authMethodsPerActor: {
|
||||
* user: ["email"],
|
||||
* customer: ["emailpass", "google"]
|
||||
* }
|
||||
* }
|
||||
* // ...
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
authMethodsPerActor?: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
* The configurations for your Medusa application are in `medusa-config.js` located in the root of your Medusa project. The configurations include configurations for database, modules, and more.
|
||||
*
|
||||
* `medusa-config.js` exports the value returned by the `defineConfig` utility function imported from `@medusajs/utils`.
|
||||
*
|
||||
* `defineConfig` accepts as a parameter an object with the following properties:
|
||||
*
|
||||
* - {@link ConfigModule.projectConfig | projectConfig} (required): An object that holds general configurations related to the Medusa application, such as database or CORS configurations.
|
||||
* - {@link ConfigModule.admin | admin}: An object that holds admin-related configurations.
|
||||
* - {@link ConfigModule.modules | modules}: An object that configures the Medusa application's modules.
|
||||
* - {@link ConfigModule.featureFlags | featureFlags}: An object that enables or disables features guarded by a feature flag.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* projectConfig: {
|
||||
* // ...
|
||||
* },
|
||||
* admin: {
|
||||
* // ...
|
||||
* },
|
||||
* modules: {
|
||||
* // ...
|
||||
* },
|
||||
* featureFlags: {
|
||||
* // ...
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* ## Environment Variables
|
||||
*
|
||||
* It's highly recommended to store the values of configurations in environment variables, then reference them within `medusa-config.js`.
|
||||
*
|
||||
* During development, you can set your environment variables in the `.env` file at the root of your Medusa application project. In production,
|
||||
* setting the environment variables depends on the hosting provider.
|
||||
*
|
||||
* ---
|
||||
*/
|
||||
export type ConfigModule = {
|
||||
/**
|
||||
* This property holds essential configurations related to the Medusa application, such as database and CORS configurations.
|
||||
*/
|
||||
projectConfig: ProjectConfigOptions
|
||||
|
||||
/**
|
||||
* This property holds configurations for the Medusa Admin dashboard.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* admin: {
|
||||
* backendUrl: process.env.MEDUSA_BACKEND_URL ||
|
||||
* "http://localhost:9000"
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
admin?: AdminOptions
|
||||
|
||||
/**
|
||||
* On your Medusa backend, you can use [Plugins](https://docs.medusajs.com/development/plugins/overview) to add custom features or integrate third-party services.
|
||||
* For example, installing a plugin to use Stripe as a payment processor.
|
||||
*
|
||||
* Aside from installing the plugin with NPM, you need to pass the plugin you installed into the `plugins` array defined in `medusa-config.js`.
|
||||
*
|
||||
* The items in the array can either be:
|
||||
*
|
||||
* - A string, which is the name of the plugin to add. You can pass a plugin as a string if it doesn’t require any configurations.
|
||||
* - An object having the following properties:
|
||||
* - `resolve`: The name of the plugin.
|
||||
* - `options`: An object that includes the plugin’s options. These options vary for each plugin, and you should refer to the plugin’s documentation for available options.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = {
|
||||
* plugins: [
|
||||
* `medusa-my-plugin-1`,
|
||||
* {
|
||||
* resolve: `medusa-my-plugin`,
|
||||
* options: {
|
||||
* apiKey: process.env.MY_API_KEY ||
|
||||
* `test`,
|
||||
* },
|
||||
* },
|
||||
* // ...
|
||||
* ],
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @ignore
|
||||
*
|
||||
* @privateRemarks
|
||||
* Added the `@\ignore` tag for now so it's not generated in the main docs until we figure out what to do with plugins
|
||||
*/
|
||||
plugins: (
|
||||
| {
|
||||
resolve: string
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
| string
|
||||
)[]
|
||||
|
||||
/**
|
||||
* This property holds all custom modules installed in your Medusa application.
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
* Medusa's commerce modules are configured by default, so only
|
||||
* add them to this property if you're changing their configurations or adding providers to a module.
|
||||
*
|
||||
* :::
|
||||
*
|
||||
* The keys of the `modules` configuration object refer to the module's registration name. Its value can be one of the following:
|
||||
*
|
||||
* 1. A boolean value indicating whether the module type is enabled. This is only supported for Medusa's commerce and architectural modules;
|
||||
* 2. Or an object having the following properties:
|
||||
* 1. `resolve`: a string indicating the path to the module relative to `src`, or the module's NPM package name. For example, `./modules/my-module`.
|
||||
* 2. `options`: (optional) an object indicating the options to pass to the module.
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* modules: {
|
||||
* helloModuleService: {
|
||||
* resolve: "./modules/hello"
|
||||
* }
|
||||
* }
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
modules?: Record<
|
||||
string,
|
||||
boolean | Partial<InternalModuleDeclaration | ExternalModuleDeclaration>
|
||||
>
|
||||
|
||||
/**
|
||||
* Some features in the Medusa application are guarded by a feature flag. This ensures constant shipping of new features while maintaining the engine’s stability.
|
||||
*
|
||||
* You can enable a feature in your application by enabling its feature flag. Feature flags are enabled through either environment
|
||||
* variables or through this configuration property exported in `medusa-config.js`.
|
||||
*
|
||||
* The `featureFlags`'s value is an object. Its properties are the names of the feature flags, and their value is a boolean indicating whether the feature flag is enabled.
|
||||
*
|
||||
* You can find available feature flags and their key name [here](https://github.com/medusajs/medusa/tree/develop/packages/medusa/src/loaders/feature-flags).
|
||||
*
|
||||
* @example
|
||||
* ```js title="medusa-config.js"
|
||||
* module.exports = defineConfig({
|
||||
* featureFlags: {
|
||||
* analytics: true,
|
||||
* // ...
|
||||
* }
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
* After enabling a feature flag, make sure to run migrations as it may require making changes to the database.
|
||||
*
|
||||
* :::
|
||||
*/
|
||||
featureFlags: Record<string, boolean | string | Record<string, boolean>>
|
||||
}
|
||||
|
||||
export type PluginDetails = {
|
||||
resolve: string
|
||||
name: string
|
||||
id: string
|
||||
options: Record<string, unknown>
|
||||
version: string
|
||||
}
|
||||
37
packages/core/framework/src/container.ts
Normal file
37
packages/core/framework/src/container.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createMedusaContainer } from "@medusajs/utils"
|
||||
import { AwilixContainer, ResolveOptions } from "awilix"
|
||||
import { TransformObjectMethodToAsync } from "@medusajs/types";
|
||||
|
||||
/**
|
||||
* The following interface acts as a bucket that other modules or the
|
||||
* utils package can fill using declaration merging
|
||||
*/
|
||||
export interface ModuleImplementations {}
|
||||
|
||||
/**
|
||||
* The Medusa Container extends [Awilix](https://github.com/jeffijoe/awilix) to
|
||||
* provide dependency injection functionalities.
|
||||
*/
|
||||
export type MedusaContainer<Cradle extends object = TransformObjectMethodToAsync<ModuleImplementations>> =
|
||||
Omit<AwilixContainer, "resolve"> & {
|
||||
resolve<K extends keyof Cradle>(
|
||||
key: K,
|
||||
resolveOptions?: ResolveOptions
|
||||
): Cradle[K]
|
||||
resolve<T>(key: string, resolveOptions?: ResolveOptions): T
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
registerAdd: <T>(name: string, registration: T) => MedusaContainer
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
createScope: () => MedusaContainer
|
||||
}
|
||||
|
||||
export type ContainerLike = {
|
||||
resolve<T = unknown>(key: string): T
|
||||
}
|
||||
|
||||
export const container = createMedusaContainer()
|
||||
1
packages/core/framework/src/database/index.ts
Normal file
1
packages/core/framework/src/database/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./pg-connection-loader"
|
||||
48
packages/core/framework/src/database/pg-connection-loader.ts
Normal file
48
packages/core/framework/src/database/pg-connection-loader.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils"
|
||||
import { asValue } from "awilix"
|
||||
import { container } from "../container"
|
||||
import { configManager } from "../config"
|
||||
|
||||
/**
|
||||
* Initialize a knex connection that can then be shared to any resources if needed
|
||||
*/
|
||||
export function pgConnectionLoader(): ReturnType<
|
||||
typeof ModulesSdkUtils.createPgConnection
|
||||
> {
|
||||
if (container.hasRegistration(ContainerRegistrationKeys.PG_CONNECTION)) {
|
||||
return container.resolve(
|
||||
ContainerRegistrationKeys.PG_CONNECTION
|
||||
) as unknown as ReturnType<typeof ModulesSdkUtils.createPgConnection>
|
||||
}
|
||||
|
||||
const configModule = configManager.config
|
||||
|
||||
// Share a knex connection to be consumed by the shared modules
|
||||
const connectionString = configModule.projectConfig.databaseUrl
|
||||
const driverOptions: any =
|
||||
configModule.projectConfig.databaseDriverOptions || {}
|
||||
const schema = configModule.projectConfig.databaseSchema || "public"
|
||||
const idleTimeoutMillis = driverOptions.pool?.idleTimeoutMillis ?? undefined // prevent null to be passed
|
||||
const poolMin = driverOptions.pool?.min ?? 2
|
||||
const poolMax = driverOptions.pool?.max
|
||||
|
||||
delete driverOptions.pool
|
||||
|
||||
const pgConnection = ModulesSdkUtils.createPgConnection({
|
||||
clientUrl: connectionString,
|
||||
schema,
|
||||
driverOptions,
|
||||
pool: {
|
||||
min: poolMin,
|
||||
max: poolMax,
|
||||
idleTimeoutMillis,
|
||||
},
|
||||
})
|
||||
|
||||
container.register(
|
||||
ContainerRegistrationKeys.PG_CONNECTION,
|
||||
asValue(pgConnection)
|
||||
)
|
||||
|
||||
return pgConnection
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { FileSystem } from "@medusajs/utils"
|
||||
import { join } from "path"
|
||||
import { featureFlagsLoader } from "../feature-flag-loader"
|
||||
import { configManager } from "../../config"
|
||||
|
||||
const filesystem = new FileSystem(join(__dirname, "__ff-test__"))
|
||||
|
||||
const buildFeatureFlag = (
|
||||
key: string,
|
||||
defaultVal: string | boolean
|
||||
): string => {
|
||||
const snakeCaseKey = key.replace(/-/g, "_")
|
||||
|
||||
return `
|
||||
export default {
|
||||
description: "${key} descr",
|
||||
key: "${snakeCaseKey}",
|
||||
env_key: "MEDUSA_FF_${snakeCaseKey.toUpperCase()}",
|
||||
default_val: ${defaultVal},
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
describe("feature flags", () => {
|
||||
const OLD_ENV = { ...process.env }
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules()
|
||||
jest.clearAllMocks()
|
||||
|
||||
process.env = { ...OLD_ENV }
|
||||
await filesystem.cleanup()
|
||||
|
||||
configManager.loadConfig({
|
||||
projectConfig: {} as any,
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
process.env = OLD_ENV
|
||||
await filesystem.cleanup()
|
||||
})
|
||||
|
||||
it("should load the flag from project", async () => {
|
||||
configManager.loadConfig({
|
||||
projectConfig: { featureFlags: { flag_1: false } },
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
|
||||
await filesystem.create("flags/flag-1.js", buildFeatureFlag("flag-1", true))
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load a nested + simple flag from project", async () => {
|
||||
configManager.loadConfig({
|
||||
projectConfig: {
|
||||
featureFlags: { test: { nested: true }, simpletest: true },
|
||||
},
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
|
||||
await filesystem.create("flags/test.js", buildFeatureFlag("test", false))
|
||||
await filesystem.create(
|
||||
"flags/simpletest.js",
|
||||
buildFeatureFlag("simpletest", false)
|
||||
)
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled({ test: "nested" })).toEqual(true)
|
||||
expect(flags.isFeatureEnabled("simpletest")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should load the default feature flags", async () => {
|
||||
await filesystem.create(
|
||||
"flags/flag-1.js",
|
||||
buildFeatureFlag("flag-1", false)
|
||||
)
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load the flag from env", async () => {
|
||||
process.env.MEDUSA_FF_FLAG_1 = "false"
|
||||
|
||||
await filesystem.create(
|
||||
"flags/flag-1.js",
|
||||
buildFeatureFlag("flag-1", false)
|
||||
)
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load mix of flags", async () => {
|
||||
configManager.loadConfig({
|
||||
projectConfig: { featureFlags: { flag_2: false } },
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
|
||||
process.env.MEDUSA_FF_FLAG_3 = "true"
|
||||
await filesystem.create(
|
||||
"flags/flag-1.js",
|
||||
buildFeatureFlag("flag-1", false)
|
||||
)
|
||||
await filesystem.create(
|
||||
"flags/flag-2.js",
|
||||
buildFeatureFlag("flag-2", false)
|
||||
)
|
||||
await filesystem.create(
|
||||
"flags/flag-3.js",
|
||||
buildFeatureFlag("flag-3", false)
|
||||
)
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
expect(flags.isFeatureEnabled("flag_2")).toEqual(false)
|
||||
expect(flags.isFeatureEnabled("flag_3")).toEqual(true)
|
||||
})
|
||||
})
|
||||
129
packages/core/framework/src/feature-flags/feature-flag-loader.ts
Normal file
129
packages/core/framework/src/feature-flags/feature-flag-loader.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
FlagRouter,
|
||||
isDefined,
|
||||
isObject,
|
||||
isString,
|
||||
isTruthy,
|
||||
objectFromStringPath,
|
||||
} from "@medusajs/utils"
|
||||
import { trackFeatureFlag } from "medusa-telemetry"
|
||||
import { join, normalize } from "path"
|
||||
import { logger } from "../logger"
|
||||
import { FlagSettings } from "./types"
|
||||
import { container } from "../container"
|
||||
import { asFunction } from "awilix"
|
||||
import { configManager } from "../config"
|
||||
import { readdir } from "fs/promises"
|
||||
|
||||
export const featureFlagRouter = new FlagRouter({})
|
||||
|
||||
container.register(
|
||||
ContainerRegistrationKeys.FEATURE_FLAG_ROUTER,
|
||||
asFunction(() => featureFlagRouter)
|
||||
)
|
||||
|
||||
const excludedFiles = ["index.js", "index.ts"]
|
||||
const excludedExtensions = [".d.ts", ".d.ts.map", ".js.map"]
|
||||
const flagConfig: Record<string, boolean | Record<string, boolean>> = {}
|
||||
|
||||
function registerFlag(
|
||||
flag: FlagSettings,
|
||||
projectConfigFlags: Record<string, string | boolean | Record<string, boolean>>
|
||||
) {
|
||||
flagConfig[flag.key] = isTruthy(flag.default_val)
|
||||
|
||||
let from
|
||||
if (isDefined(process.env[flag.env_key])) {
|
||||
from = "environment"
|
||||
const envVal = process.env[flag.env_key]
|
||||
|
||||
// MEDUSA_FF_ANALYTICS="true"
|
||||
flagConfig[flag.key] = isTruthy(process.env[flag.env_key])
|
||||
|
||||
const parsedFromEnv = isString(envVal) ? envVal.split(",") : []
|
||||
|
||||
// MEDUSA_FF_WORKFLOWS=createProducts,deleteProducts
|
||||
if (parsedFromEnv.length > 1) {
|
||||
flagConfig[flag.key] = objectFromStringPath(parsedFromEnv)
|
||||
}
|
||||
} else if (isDefined(projectConfigFlags[flag.key])) {
|
||||
from = "project config"
|
||||
|
||||
// featureFlags: { analytics: "true" | true }
|
||||
flagConfig[flag.key] = isTruthy(
|
||||
projectConfigFlags[flag.key] as string | boolean
|
||||
)
|
||||
|
||||
// featureFlags: { workflows: { createProducts: true } }
|
||||
if (isObject(projectConfigFlags[flag.key])) {
|
||||
flagConfig[flag.key] = projectConfigFlags[flag.key] as Record<
|
||||
string,
|
||||
boolean
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
if (logger && from) {
|
||||
logger.info(
|
||||
`Using flag ${flag.env_key} from ${from} with value ${
|
||||
flagConfig[flag.key]
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
if (flagConfig[flag.key]) {
|
||||
trackFeatureFlag(flag.key)
|
||||
}
|
||||
|
||||
featureFlagRouter.setFlag(flag.key, flagConfig[flag.key])
|
||||
}
|
||||
|
||||
/**
|
||||
* Load feature flags from a directory and from the already loaded config under the hood
|
||||
* @param sourcePath
|
||||
*/
|
||||
export async function featureFlagsLoader(
|
||||
sourcePath?: string
|
||||
): Promise<FlagRouter> {
|
||||
const { featureFlags: projectConfigFlags = {} } = configManager.config
|
||||
|
||||
if (!sourcePath) {
|
||||
return featureFlagRouter
|
||||
}
|
||||
|
||||
const flagDir = normalize(sourcePath)
|
||||
|
||||
await readdir(flagDir, { recursive: true, withFileTypes: true }).then(
|
||||
async (files) => {
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
files.map(async (file) => {
|
||||
if (file.isDirectory()) {
|
||||
return await featureFlagsLoader(join(flagDir, file.name))
|
||||
}
|
||||
|
||||
if (
|
||||
excludedExtensions.some((ext) => file.name.endsWith(ext)) ||
|
||||
excludedFiles.includes(file.name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileExports = await import(join(flagDir, file.name))
|
||||
const featureFlag = fileExports.default
|
||||
|
||||
if (!featureFlag) {
|
||||
return
|
||||
}
|
||||
|
||||
registerFlag(featureFlag, projectConfigFlags)
|
||||
return
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return featureFlagRouter
|
||||
}
|
||||
77
packages/core/framework/src/feature-flags/flag-router.ts
Normal file
77
packages/core/framework/src/feature-flags/flag-router.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { isObject, isString } from "@medusajs/utils"
|
||||
import { FeatureFlagsResponse, IFlagRouter } from "./types"
|
||||
|
||||
export class FlagRouter implements IFlagRouter {
|
||||
private readonly flags: Record<string, boolean | Record<string, boolean>> = {}
|
||||
|
||||
constructor(flags: Record<string, boolean | Record<string, boolean>>) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled.
|
||||
* There are two ways of using this method:
|
||||
* 1. `isFeatureEnabled("myFeatureFlag")`
|
||||
* 2. `isFeatureEnabled({ myNestedFeatureFlag: "someNestedFlag" })`
|
||||
* We use 1. for top-level feature flags and 2. for nested feature flags. Almost all flags are top-level.
|
||||
* An example of a nested flag is workflows. To use it, you would do:
|
||||
* `isFeatureEnabled({ workflows: Workflows.CreateCart })`
|
||||
* @param flag - The flag to check
|
||||
* @return {boolean} - Whether the flag is enabled or not
|
||||
*/
|
||||
public isFeatureEnabled(
|
||||
flag: string | string[] | Record<string, string>
|
||||
): boolean {
|
||||
if (isObject(flag)) {
|
||||
const [nestedFlag, value] = Object.entries(flag)[0]
|
||||
if (typeof this.flags[nestedFlag] === "boolean") {
|
||||
return this.flags[nestedFlag] as boolean
|
||||
}
|
||||
return !!this.flags[nestedFlag]?.[value]
|
||||
}
|
||||
|
||||
const flags = (Array.isArray(flag) ? flag : [flag]) as string[]
|
||||
return flags.every((flag_) => {
|
||||
if (!isString(flag_)) {
|
||||
throw Error("Flag must be a string an array of string or an object")
|
||||
}
|
||||
return !!this.flags[flag_]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a feature flag.
|
||||
* Flags take two shapes:
|
||||
* `setFlag("myFeatureFlag", true)`
|
||||
* `setFlag("myFeatureFlag", { nestedFlag: true })`
|
||||
* These shapes are used for top-level and nested flags respectively, as explained in isFeatureEnabled.
|
||||
* @param key - The key of the flag to set.
|
||||
* @param value - The value of the flag to set.
|
||||
* @return {void} - void
|
||||
*/
|
||||
public setFlag(
|
||||
key: string,
|
||||
value: boolean | { [key: string]: boolean }
|
||||
): void {
|
||||
if (isObject(value)) {
|
||||
const existing = this.flags[key]
|
||||
|
||||
if (!existing) {
|
||||
this.flags[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
this.flags[key] = { ...(this.flags[key] as object), ...value }
|
||||
return
|
||||
}
|
||||
|
||||
this.flags[key] = value
|
||||
}
|
||||
|
||||
public listFlags(): FeatureFlagsResponse {
|
||||
return Object.entries(this.flags || {}).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}))
|
||||
}
|
||||
}
|
||||
3
packages/core/framework/src/feature-flags/index.ts
Normal file
3
packages/core/framework/src/feature-flags/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./feature-flag-loader"
|
||||
export * from "./flag-router"
|
||||
32
packages/core/framework/src/feature-flags/types.ts
Normal file
32
packages/core/framework/src/feature-flags/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface IFlagRouter {
|
||||
isFeatureEnabled: (key: string) => boolean
|
||||
listFlags: () => FeatureFlagsResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @schema FeatureFlagsResponse
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required:
|
||||
* - key
|
||||
* - value
|
||||
* properties:
|
||||
* key:
|
||||
* description: The key of the feature flag.
|
||||
* type: string
|
||||
* value:
|
||||
* description: The value of the feature flag.
|
||||
* type: boolean
|
||||
*/
|
||||
export type FeatureFlagsResponse = {
|
||||
key: string
|
||||
value: boolean | Record<string, boolean>
|
||||
}[]
|
||||
|
||||
export type FlagSettings = {
|
||||
key: string
|
||||
description: string
|
||||
env_key: string
|
||||
default_val: boolean
|
||||
}
|
||||
20
packages/core/framework/src/http/__fixtures__/mocks/index.ts
Normal file
20
packages/core/framework/src/http/__fixtures__/mocks/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ConfigModule } from "@medusajs/types"
|
||||
|
||||
export const customersGlobalMiddlewareMock = jest.fn()
|
||||
export const customersCreateMiddlewareMock = jest.fn()
|
||||
export const storeGlobalMiddlewareMock = jest.fn()
|
||||
|
||||
export const config: ConfigModule = {
|
||||
projectConfig: {
|
||||
databaseLogging: false,
|
||||
http: {
|
||||
authCors: "http://localhost:9000",
|
||||
storeCors: "http://localhost:8000",
|
||||
adminCors: "http://localhost:7001",
|
||||
jwtSecret: "supersecret",
|
||||
cookieSecret: "superSecret",
|
||||
},
|
||||
},
|
||||
featureFlags: {},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = (req: Request, res: Response) => {
|
||||
res.send("get customer order")
|
||||
}
|
||||
|
||||
export const POST = (req: Request, res: Response) => {
|
||||
res.send("update customer order")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineMiddlewares } from "../../utils/define-middlewares"
|
||||
|
||||
export default defineMiddlewares({
|
||||
errorHandler: (err, _req, res, _next) => {
|
||||
const { code, message } = err
|
||||
|
||||
switch (code) {
|
||||
case "NOT_ALLOWED":
|
||||
res.status(405).json({
|
||||
type: code.toLowerCase(),
|
||||
message,
|
||||
})
|
||||
break
|
||||
case "INVALID_DATA":
|
||||
res.status(400).json({
|
||||
type: code.toLowerCase(),
|
||||
message,
|
||||
})
|
||||
break
|
||||
case "CONFLICT":
|
||||
res.status(409).json({
|
||||
type: code.toLowerCase(),
|
||||
message,
|
||||
})
|
||||
break
|
||||
case "TEAPOT":
|
||||
res.status(418).json({
|
||||
type: code.toLowerCase(),
|
||||
message,
|
||||
})
|
||||
break
|
||||
default:
|
||||
res.status(500).json({
|
||||
type: "unknown_error",
|
||||
message: "An unknown error occurred.",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
export const GET = async (req: Request, res: Response) => {
|
||||
throw {
|
||||
code: "NOT_ALLOWED",
|
||||
message: "Not allowed to perform this action",
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = async (req: Request, res: Response) => {
|
||||
throw {
|
||||
code: "INVALID_DATA",
|
||||
message: "Invalid data provided",
|
||||
}
|
||||
}
|
||||
|
||||
export const PUT = async (req: Request, res: Response) => {
|
||||
throw {
|
||||
code: "CONFLICT",
|
||||
message: "Conflict with another request",
|
||||
}
|
||||
}
|
||||
|
||||
export const DELETE = async (req: Request, res: Response) => {
|
||||
throw {
|
||||
code: "TEAPOT",
|
||||
message: "I'm a teapot",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = (req: Request, res: Response) => {
|
||||
res.send(`GET /admin/protected`)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const AUTHENTICATE = false
|
||||
|
||||
export const GET = (req: Request, res: Response) => {
|
||||
res.send(`GET /admin/unprotected`)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Request, Response } from "express"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
export const GET = async (req: Request, res: Response) => {
|
||||
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not allowed")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
res.send("list customers")
|
||||
}
|
||||
|
||||
export function POST(req: Request, res: Response) {
|
||||
res.send("create customer")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextFunction, raw, Request, Response } from "express"
|
||||
import {
|
||||
customersCreateMiddlewareMock,
|
||||
customersGlobalMiddlewareMock,
|
||||
storeGlobalMiddlewareMock,
|
||||
} from "../mocks"
|
||||
import { defineMiddlewares } from "../../utils/define-middlewares"
|
||||
|
||||
const customersGlobalMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
customersGlobalMiddlewareMock()
|
||||
next()
|
||||
}
|
||||
|
||||
const customersCreateMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
customersCreateMiddlewareMock()
|
||||
next()
|
||||
}
|
||||
|
||||
const storeGlobal = (req: Request, res: Response, next: NextFunction) => {
|
||||
storeGlobalMiddlewareMock()
|
||||
next()
|
||||
}
|
||||
|
||||
export default defineMiddlewares([
|
||||
{
|
||||
matcher: "/customers",
|
||||
middlewares: [customersGlobalMiddleware],
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
matcher: "/customers",
|
||||
middlewares: [customersCreateMiddleware],
|
||||
},
|
||||
{
|
||||
matcher: "/store/*",
|
||||
middlewares: [storeGlobal],
|
||||
},
|
||||
{
|
||||
matcher: "/webhooks/*",
|
||||
method: "POST",
|
||||
bodyParser: false,
|
||||
middlewares: [raw({ type: "application/json" })],
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = async (req: Request, res: Response) => {
|
||||
res.send(`GET /store/protected`)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const AUTHENTICATE = false
|
||||
|
||||
export const GET = async (req: Request, res: Response) => {
|
||||
res.send(`GET /store/unprotected`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const POST = (req: Request, res: Response) => {
|
||||
res.send(`sync product ${req.params.id}`)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const POST = (req: Request, res: Response) => {
|
||||
if (!(req.body instanceof Buffer)) {
|
||||
res.status(400).send("Invalid body")
|
||||
}
|
||||
|
||||
res.send("OK")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = async (req: Request, res: Response): Promise<void> => {
|
||||
res.send(`GET private route`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
res.send(`GET order ${req.params.id}`)
|
||||
} catch (err) {
|
||||
res.status(400).send(err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
res.send(`POST order ${req.params.id}`)
|
||||
} catch (err) {
|
||||
res.status(400).send(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
res.send("hello world")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function POST(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function PUT(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function OPTIONS(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function HEAD(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
|
||||
export async function POST(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
/* const customerId = req.params.id;
|
||||
const orderId = req.params.id;*/
|
||||
res.send("list customers " + JSON.stringify(req.params))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
res.send("list customers")
|
||||
}
|
||||
181
packages/core/framework/src/http/__fixtures__/server/index.ts
Normal file
181
packages/core/framework/src/http/__fixtures__/server/index.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
moduleLoader,
|
||||
ModulesDefinition,
|
||||
registerMedusaModule,
|
||||
} from "@medusajs/modules-sdk"
|
||||
import { ContainerRegistrationKeys, generateJwtToken } from "@medusajs/utils"
|
||||
import { asValue } from "awilix"
|
||||
import express from "express"
|
||||
import querystring from "querystring"
|
||||
import supertest from "supertest"
|
||||
|
||||
import { config } from "../mocks"
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import { configManager, ConfigModule } from "../../../config"
|
||||
import { container } from "../../../container"
|
||||
import { featureFlagsLoader } from "../../../feature-flags"
|
||||
import { logger } from "../../../logger"
|
||||
import { MedusaRequest } from "../../types"
|
||||
import { RoutesLoader } from "../../router"
|
||||
|
||||
function asArray(resolvers) {
|
||||
return {
|
||||
resolve: (container) =>
|
||||
resolvers.map((resolver) => container.build(resolver)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a test server that injects API Routes using the RoutesLoader
|
||||
*
|
||||
* @param {String} rootDir - The root directory of the project
|
||||
*/
|
||||
export const createServer = async (rootDir) => {
|
||||
const app = express()
|
||||
|
||||
const moduleResolutions = {}
|
||||
Object.entries(ModulesDefinition).forEach(([moduleKey, module]) => {
|
||||
moduleResolutions[moduleKey] = registerMedusaModule(
|
||||
moduleKey,
|
||||
module.defaultModuleDeclaration,
|
||||
undefined,
|
||||
module
|
||||
)[moduleKey]
|
||||
})
|
||||
|
||||
configManager.loadConfig({
|
||||
projectConfig: config as ConfigModule,
|
||||
baseDir: rootDir,
|
||||
})
|
||||
|
||||
container.registerAdd = function (this: MedusaContainer, name, registration) {
|
||||
const storeKey = name + "_STORE"
|
||||
|
||||
if (this.registrations[storeKey] === undefined) {
|
||||
this.register(storeKey, asValue([]))
|
||||
}
|
||||
const store = this.resolve(storeKey) as Array<any>
|
||||
|
||||
if (this.registrations[name] === undefined) {
|
||||
this.register(name, asArray(store))
|
||||
}
|
||||
store.unshift(registration)
|
||||
|
||||
return this
|
||||
}.bind(container)
|
||||
|
||||
container.register(ContainerRegistrationKeys.PG_CONNECTION, asValue({}))
|
||||
container.register("configModule", asValue(config))
|
||||
container.register({
|
||||
logger: asValue({
|
||||
error: () => {},
|
||||
}),
|
||||
manager: asValue({}),
|
||||
})
|
||||
|
||||
app.set("trust proxy", 1)
|
||||
app.use((req, _res, next) => {
|
||||
req["session"] = {}
|
||||
const data = req.get("Cookie")
|
||||
if (data) {
|
||||
req["session"] = {
|
||||
...req["session"],
|
||||
...JSON.parse(data),
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
await featureFlagsLoader()
|
||||
await moduleLoader({ container, moduleResolutions, logger })
|
||||
|
||||
app.use((req, res, next) => {
|
||||
;(req as MedusaRequest).scope = container.createScope() as MedusaContainer
|
||||
next()
|
||||
})
|
||||
|
||||
await new RoutesLoader({
|
||||
app,
|
||||
sourceDir: rootDir,
|
||||
}).load()
|
||||
|
||||
const superRequest = supertest(app)
|
||||
|
||||
return {
|
||||
request: async (method, url, opts: any = {}) => {
|
||||
const { payload, query, headers = {} } = opts
|
||||
|
||||
const queryParams = query && querystring.stringify(query)
|
||||
const req = superRequest[method.toLowerCase()](
|
||||
`${url}${queryParams ? "?" + queryParams : ""}`
|
||||
)
|
||||
headers.Cookie = headers.Cookie || ""
|
||||
if (opts.adminSession) {
|
||||
const token = generateJwtToken(
|
||||
{
|
||||
actor_id: opts.adminSession.userId || opts.adminSession.jwt?.userId,
|
||||
actor_type: "user",
|
||||
app_metadata: {
|
||||
user_id:
|
||||
opts.adminSession.userId || opts.adminSession.jwt?.userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
secret: config.projectConfig.http.jwtSecret!,
|
||||
expiresIn: "1d",
|
||||
}
|
||||
)
|
||||
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (opts.clientSession) {
|
||||
const token = generateJwtToken(
|
||||
{
|
||||
actor_id:
|
||||
opts.clientSession.customer_id ||
|
||||
opts.clientSession.jwt?.customer_id,
|
||||
actor_type: "customer",
|
||||
app_metadata: {
|
||||
customer_id:
|
||||
opts.clientSession.customer_id ||
|
||||
opts.clientSession.jwt?.customer_id,
|
||||
},
|
||||
},
|
||||
{ secret: config.projectConfig.http.jwtSecret!, expiresIn: "1d" }
|
||||
)
|
||||
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
for (const name in headers) {
|
||||
if ({}.hasOwnProperty.call(headers, name)) {
|
||||
req.set(name, headers[name])
|
||||
}
|
||||
}
|
||||
|
||||
if (payload && !req.get("content-type")) {
|
||||
req.set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if (!req.get("accept")) {
|
||||
req.set("Accept", "application/json")
|
||||
}
|
||||
|
||||
req.set("Host", "localhost")
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await req.send(JSON.stringify(payload))
|
||||
} catch (e) {
|
||||
if (e.response) {
|
||||
res = e.response
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
}
|
||||
}
|
||||
243
packages/core/framework/src/http/__tests__/index.spec.ts
Normal file
243
packages/core/framework/src/http/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import express from "express"
|
||||
import { resolve } from "path"
|
||||
import {
|
||||
customersCreateMiddlewareMock,
|
||||
customersGlobalMiddlewareMock,
|
||||
storeGlobalMiddlewareMock,
|
||||
} from "../__fixtures__/mocks"
|
||||
import { createServer } from "../__fixtures__/server"
|
||||
import { RoutesLoader } from "../index"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("RoutesLoader", function () {
|
||||
afterEach(function () {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("Routes", function () {
|
||||
let request
|
||||
|
||||
beforeAll(async function () {
|
||||
const rootDir = resolve(__dirname, "../__fixtures__/routers")
|
||||
|
||||
const { request: request_ } = await createServer(rootDir)
|
||||
|
||||
request = request_
|
||||
})
|
||||
|
||||
it("should return a status 200 on GET admin/order/:id", async function () {
|
||||
const res = await request("GET", "/admin/orders/1000", {
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: "admin_user",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("GET order 1000")
|
||||
})
|
||||
|
||||
it("should return a status 200 on POST admin/order/:id", async function () {
|
||||
const res = await request("POST", "/admin/orders/1000", {
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: "admin_user",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("POST order 1000")
|
||||
})
|
||||
|
||||
it("should call GET /customers/[customer_id]/orders/[order_id]", async function () {
|
||||
const res = await request("GET", "/customers/test-customer/orders/test")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe(
|
||||
'list customers {"customer_id":"test-customer","order_id":"test"}'
|
||||
)
|
||||
})
|
||||
|
||||
it("should not be able to GET /_private as the folder is prefixed with an underscore", async function () {
|
||||
const res = await request("GET", "/_private")
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(res.text).toContain("Cannot GET /_private")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Middlewares", function () {
|
||||
let request
|
||||
|
||||
beforeAll(async function () {
|
||||
const rootDir = resolve(__dirname, "../__fixtures__/routers-middleware")
|
||||
|
||||
const { request: request_ } = await createServer(rootDir)
|
||||
|
||||
request = request_
|
||||
})
|
||||
|
||||
it("should call middleware applied to `/customers`", async function () {
|
||||
const res = await request("GET", "/customers")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("list customers")
|
||||
expect(customersGlobalMiddlewareMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not call middleware applied to POST `/customers` when GET `/customers`", async function () {
|
||||
const res = await request("GET", "/customers")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("list customers")
|
||||
expect(customersGlobalMiddlewareMock).toHaveBeenCalled()
|
||||
expect(customersCreateMiddlewareMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call middleware applied to POST `/customers` when POST `/customers`", async function () {
|
||||
const res = await request("POST", "/customers")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("create customer")
|
||||
expect(customersGlobalMiddlewareMock).toHaveBeenCalled()
|
||||
expect(customersCreateMiddlewareMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call store global middleware on `/store/*` routes", async function () {
|
||||
const res = await request("POST", "/store/products/1000/sync")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("sync product 1000")
|
||||
expect(storeGlobalMiddlewareMock).toHaveBeenCalled()
|
||||
|
||||
expect(customersGlobalMiddlewareMock).not.toHaveBeenCalled()
|
||||
expect(customersCreateMiddlewareMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should apply raw middleware on POST `/webhooks/payment` route", async function () {
|
||||
const res = await request("POST", "/webhooks/payment", {
|
||||
payload: { test: "test" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("OK")
|
||||
})
|
||||
|
||||
it("should return 200 when admin is authenticated", async () => {
|
||||
const res = await request("GET", "/admin/protected", {
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: "admin_user",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("GET /admin/protected")
|
||||
})
|
||||
|
||||
it.skip("should return 401 when admin is not authenticated", async () => {
|
||||
const res = await request("GET", "/admin/protected")
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.text).toBe("Unauthorized")
|
||||
})
|
||||
|
||||
it("should return 200 when admin route is opted out of authentication", async () => {
|
||||
const res = await request("GET", "/admin/unprotected")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.text).toBe("GET /admin/unprotected")
|
||||
})
|
||||
|
||||
it("should return the error as JSON when an error is thrown with default error handling", async () => {
|
||||
const res = await request("GET", "/customers/error")
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body).toEqual({
|
||||
message: "Not allowed",
|
||||
type: "not_allowed",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Custom error handling", function () {
|
||||
let request
|
||||
|
||||
beforeAll(async function () {
|
||||
const rootDir = resolve(
|
||||
__dirname,
|
||||
"../__fixtures__/routers-error-handler"
|
||||
)
|
||||
|
||||
const { request: request_ } = await createServer(rootDir)
|
||||
|
||||
request = request_
|
||||
})
|
||||
|
||||
it("should return 405 when NOT_ALLOWED error is thrown", async () => {
|
||||
const res = await request("GET", "/store")
|
||||
|
||||
expect(res.status).toBe(405)
|
||||
expect(res.body).toEqual({
|
||||
message: "Not allowed to perform this action",
|
||||
type: "not_allowed",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 400 when INVALID_DATA error is thrown", async () => {
|
||||
const res = await request("POST", "/store")
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body).toEqual({
|
||||
message: "Invalid data provided",
|
||||
type: "invalid_data",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 409 when CONFLICT error is thrown", async () => {
|
||||
const res = await request("PUT", "/store")
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
expect(res.body).toEqual({
|
||||
message: "Conflict with another request",
|
||||
type: "conflict",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 418 when TEAPOT error is thrown", async () => {
|
||||
const res = await request("DELETE", "/store")
|
||||
|
||||
expect(res.status).toBe(418)
|
||||
expect(res.body).toEqual({
|
||||
message: "I'm a teapot",
|
||||
type: "teapot",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Duplicate parameters", function () {
|
||||
const app = express()
|
||||
|
||||
it("should throw if a route contains the same parameter multiple times", async function () {
|
||||
const rootDir = resolve(
|
||||
__dirname,
|
||||
"../__fixtures__/routers-duplicate-parameter"
|
||||
)
|
||||
const err = await new RoutesLoader({
|
||||
app,
|
||||
sourceDir: rootDir,
|
||||
})
|
||||
.load()
|
||||
.catch((e) => e)
|
||||
|
||||
expect(err).toBeDefined()
|
||||
expect(err.message).toBe(
|
||||
"Duplicate parameters found in route /admin/customers/[id]/orders/[id] (id). Make sure that all parameters are unique."
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
78
packages/core/framework/src/http/express-loader.ts
Normal file
78
packages/core/framework/src/http/express-loader.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import createStore from "connect-redis"
|
||||
import cookieParser from "cookie-parser"
|
||||
import express, { Express } from "express"
|
||||
import session from "express-session"
|
||||
import Redis from "ioredis"
|
||||
import morgan from "morgan"
|
||||
import path from "path"
|
||||
import { configManager } from "../config"
|
||||
|
||||
export async function expressLoader({ app }: { app: Express }): Promise<{
|
||||
app: Express
|
||||
shutdown: () => Promise<void>
|
||||
}> {
|
||||
const baseDir = configManager.baseDir
|
||||
const configModule = configManager.config
|
||||
const isProduction = configManager.isProduction
|
||||
const isStaging = process.env.NODE_ENV === "staging"
|
||||
const isTest = process.env.NODE_ENV === "test"
|
||||
|
||||
let sameSite: string | boolean = false
|
||||
let secure = false
|
||||
if (isProduction || isStaging) {
|
||||
secure = true
|
||||
sameSite = "none"
|
||||
}
|
||||
|
||||
const { http, sessionOptions } = configModule.projectConfig
|
||||
const sessionOpts = {
|
||||
name: sessionOptions?.name ?? "connect.sid",
|
||||
resave: sessionOptions?.resave ?? true,
|
||||
rolling: sessionOptions?.rolling ?? false,
|
||||
saveUninitialized: sessionOptions?.saveUninitialized ?? true,
|
||||
proxy: true,
|
||||
secret: sessionOptions?.secret ?? http?.cookieSecret,
|
||||
cookie: {
|
||||
sameSite,
|
||||
secure,
|
||||
maxAge: sessionOptions?.ttl ?? 10 * 60 * 60 * 1000,
|
||||
},
|
||||
store: null,
|
||||
}
|
||||
|
||||
let redisClient
|
||||
|
||||
if (configModule?.projectConfig?.redisUrl) {
|
||||
const RedisStore = createStore(session)
|
||||
redisClient = new Redis(
|
||||
configModule.projectConfig.redisUrl,
|
||||
configModule.projectConfig.redisOptions ?? {}
|
||||
)
|
||||
sessionOpts.store = new RedisStore({
|
||||
client: redisClient,
|
||||
prefix: `${configModule?.projectConfig?.redisPrefix ?? ""}sess:`,
|
||||
})
|
||||
}
|
||||
|
||||
app.set("trust proxy", 1)
|
||||
app.use(
|
||||
morgan("combined", {
|
||||
skip: () => isTest,
|
||||
})
|
||||
)
|
||||
app.use(cookieParser())
|
||||
app.use(session(sessionOpts))
|
||||
|
||||
// Currently we don't allow configuration of static files, but this can be revisited as needed.
|
||||
app.use("/static", express.static(path.join(baseDir, "static")))
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).send("OK")
|
||||
})
|
||||
|
||||
const shutdown = async () => {
|
||||
redisClient?.disconnect()
|
||||
}
|
||||
|
||||
return { app, shutdown }
|
||||
}
|
||||
5
packages/core/framework/src/http/index.ts
Normal file
5
packages/core/framework/src/http/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./express-loader"
|
||||
export * from "./router"
|
||||
export * from "./types"
|
||||
export * from "./middlewares"
|
||||
export * from "./utils/define-middlewares"
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ApiKeyDTO, IApiKeyModuleService } from "@medusajs/types"
|
||||
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
|
||||
import { NextFunction, RequestHandler } from "express"
|
||||
import { JwtPayload, verify } from "jsonwebtoken"
|
||||
import { ConfigModule } from "../../config"
|
||||
import {
|
||||
AuthContext,
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../types"
|
||||
|
||||
const SESSION_AUTH = "session"
|
||||
const BEARER_AUTH = "bearer"
|
||||
const API_KEY_AUTH = "api-key"
|
||||
|
||||
// This is the only hard-coded actor type, as API keys have special handling for now. We could also generalize API keys to carry the actor type with them.
|
||||
const ADMIN_ACTOR_TYPE = "user"
|
||||
|
||||
export type AuthType =
|
||||
| typeof SESSION_AUTH
|
||||
| typeof BEARER_AUTH
|
||||
| typeof API_KEY_AUTH
|
||||
|
||||
type MedusaSession = {
|
||||
auth_context: AuthContext
|
||||
}
|
||||
|
||||
export const authenticate = (
|
||||
actorType: string | string[],
|
||||
authType: AuthType | AuthType[],
|
||||
options: { allowUnauthenticated?: boolean; allowUnregistered?: boolean } = {}
|
||||
): RequestHandler => {
|
||||
const authenticateMiddleware = async (
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const authTypes = Array.isArray(authType) ? authType : [authType]
|
||||
const actorTypes = Array.isArray(actorType) ? actorType : [actorType]
|
||||
const req_ = req as AuthenticatedMedusaRequest
|
||||
|
||||
// We only allow authenticating using a secret API key on the admin
|
||||
const isExclusivelyUser =
|
||||
actorTypes.length === 1 && actorTypes[0] === ADMIN_ACTOR_TYPE
|
||||
|
||||
if (authTypes.includes(API_KEY_AUTH) && isExclusivelyUser) {
|
||||
const apiKey = await getApiKeyInfo(req)
|
||||
if (apiKey) {
|
||||
req_.auth_context = {
|
||||
actor_id: apiKey.id,
|
||||
actor_type: "api-key",
|
||||
auth_identity_id: "",
|
||||
app_metadata: {},
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// We try to extract the auth context either from the session or from a JWT token
|
||||
let authContext: AuthContext | null = getAuthContextFromSession(
|
||||
req.session,
|
||||
authTypes,
|
||||
actorTypes
|
||||
)
|
||||
|
||||
if (!authContext) {
|
||||
const {
|
||||
projectConfig: { http },
|
||||
} = req.scope.resolve<ConfigModule>(
|
||||
ContainerRegistrationKeys.CONFIG_MODULE
|
||||
)
|
||||
|
||||
authContext = getAuthContextFromJwtToken(
|
||||
req.headers.authorization,
|
||||
http.jwtSecret!,
|
||||
authTypes,
|
||||
actorTypes
|
||||
)
|
||||
}
|
||||
|
||||
// If the entity is authenticated, and it is a registered actor we can continue
|
||||
if (authContext?.actor_id) {
|
||||
req_.auth_context = authContext
|
||||
return next()
|
||||
}
|
||||
|
||||
// If the entity is authenticated, but there is no registered actor yet, we can continue (eg. in the case of a user invite) if allow unregistered is set
|
||||
// We also don't want to allow creating eg. a customer with a token created for a `user` provider.
|
||||
if (
|
||||
authContext?.auth_identity_id &&
|
||||
options.allowUnregistered &&
|
||||
isActorTypePermitted(actorTypes, authContext.actor_type)
|
||||
) {
|
||||
req_.auth_context = authContext
|
||||
return next()
|
||||
}
|
||||
|
||||
// If we allow unauthenticated requests (i.e public endpoints), just continue
|
||||
if (options.allowUnauthenticated) {
|
||||
return next()
|
||||
}
|
||||
|
||||
res.status(401).json({ message: "Unauthorized" })
|
||||
}
|
||||
|
||||
return authenticateMiddleware as unknown as RequestHandler
|
||||
}
|
||||
|
||||
const getApiKeyInfo = async (req: MedusaRequest): Promise<ApiKeyDTO | null> => {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [tokenType, token] = authHeader.split(" ")
|
||||
if (tokenType.toLowerCase() !== "basic" || !token) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The token could have been base64 encoded, we want to decode it first.
|
||||
let normalizedToken = token
|
||||
if (!token.startsWith("sk_")) {
|
||||
normalizedToken = Buffer.from(token, "base64").toString("utf-8")
|
||||
}
|
||||
|
||||
// Basic auth is defined as a username:password set, and since the token is set to the username we need to trim the colon
|
||||
if (normalizedToken.endsWith(":")) {
|
||||
normalizedToken = normalizedToken.slice(0, -1)
|
||||
}
|
||||
|
||||
// Secret tokens start with 'sk_', and if it doesn't it could be a user JWT or a malformed token
|
||||
if (!normalizedToken.startsWith("sk_")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const apiKeyModule = req.scope.resolve(
|
||||
Modules.API_KEY
|
||||
) as IApiKeyModuleService
|
||||
try {
|
||||
const apiKey = await apiKeyModule.authenticate(normalizedToken)
|
||||
if (!apiKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return apiKey
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthContextFromSession = (
|
||||
session: Partial<MedusaSession> = {},
|
||||
authTypes: AuthType[],
|
||||
actorTypes: string[]
|
||||
): AuthContext | null => {
|
||||
if (!authTypes.includes(SESSION_AUTH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
session.auth_context &&
|
||||
isActorTypePermitted(actorTypes, session.auth_context?.actor_type)
|
||||
) {
|
||||
return session.auth_context
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getAuthContextFromJwtToken = (
|
||||
authHeader: string | undefined,
|
||||
jwtSecret: string,
|
||||
authTypes: AuthType[],
|
||||
actorTypes: string[]
|
||||
): AuthContext | null => {
|
||||
if (!authTypes.includes(BEARER_AUTH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!authHeader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const re = /(\S+)\s+(\S+)/
|
||||
const matches = authHeader.match(re)
|
||||
|
||||
// TODO: figure out how to obtain token (and store correct data in token)
|
||||
if (matches) {
|
||||
const tokenType = matches[1]
|
||||
const token = matches[2]
|
||||
if (tokenType.toLowerCase() === BEARER_AUTH) {
|
||||
// get config jwt secret
|
||||
// verify token and set authUser
|
||||
try {
|
||||
const verified = verify(token, jwtSecret) as JwtPayload
|
||||
if (isActorTypePermitted(actorTypes, verified.actor_type)) {
|
||||
return verified as AuthContext
|
||||
}
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const isActorTypePermitted = (
|
||||
actorTypes: string | string[],
|
||||
currentActorType: string
|
||||
) => {
|
||||
return actorTypes.includes("*") || actorTypes.includes(currentActorType)
|
||||
}
|
||||
102
packages/core/framework/src/http/middlewares/error-handler.ts
Normal file
102
packages/core/framework/src/http/middlewares/error-handler.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextFunction, Response } from "express"
|
||||
|
||||
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils"
|
||||
import { formatException } from "./exception-formatter"
|
||||
import { MedusaRequest } from "../types"
|
||||
import { logger as logger_ } from "../../logger"
|
||||
|
||||
const QUERY_RUNNER_RELEASED = "QueryRunnerAlreadyReleasedError"
|
||||
const TRANSACTION_STARTED = "TransactionAlreadyStartedError"
|
||||
const TRANSACTION_NOT_STARTED = "TransactionNotStartedError"
|
||||
|
||||
const API_ERROR = "api_error"
|
||||
const INVALID_REQUEST_ERROR = "invalid_request_error"
|
||||
const INVALID_STATE_ERROR = "invalid_state_error"
|
||||
|
||||
export function errorHandler() {
|
||||
return (
|
||||
err: MedusaError,
|
||||
req: MedusaRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const logger: typeof logger_ = req.scope.resolve(
|
||||
ContainerRegistrationKeys.LOGGER
|
||||
)
|
||||
|
||||
err = formatException(err)
|
||||
|
||||
logger.error(err)
|
||||
|
||||
const errorType = err.type || err.name
|
||||
|
||||
const errObj = {
|
||||
code: err.code,
|
||||
type: err.type,
|
||||
message: err.message,
|
||||
}
|
||||
|
||||
let statusCode = 500
|
||||
switch (errorType) {
|
||||
case QUERY_RUNNER_RELEASED:
|
||||
case TRANSACTION_STARTED:
|
||||
case TRANSACTION_NOT_STARTED:
|
||||
case MedusaError.Types.CONFLICT:
|
||||
statusCode = 409
|
||||
errObj.code = INVALID_STATE_ERROR
|
||||
errObj.message =
|
||||
"The request conflicted with another request. You may retry the request with the provided Idempotency-Key."
|
||||
break
|
||||
case MedusaError.Types.UNAUTHORIZED:
|
||||
statusCode = 401
|
||||
break
|
||||
case MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR:
|
||||
statusCode = 422
|
||||
break
|
||||
case MedusaError.Types.DUPLICATE_ERROR:
|
||||
statusCode = 422
|
||||
errObj.code = INVALID_REQUEST_ERROR
|
||||
break
|
||||
case MedusaError.Types.NOT_ALLOWED:
|
||||
case MedusaError.Types.INVALID_DATA:
|
||||
statusCode = 400
|
||||
break
|
||||
case MedusaError.Types.NOT_FOUND:
|
||||
statusCode = 404
|
||||
break
|
||||
case MedusaError.Types.DB_ERROR:
|
||||
statusCode = 500
|
||||
errObj.code = API_ERROR
|
||||
break
|
||||
case MedusaError.Types.UNEXPECTED_STATE:
|
||||
case MedusaError.Types.INVALID_ARGUMENT:
|
||||
break
|
||||
default:
|
||||
errObj.code = "unknown_error"
|
||||
errObj.message = "An unknown error occurred."
|
||||
errObj.type = "unknown_error"
|
||||
break
|
||||
}
|
||||
|
||||
res.status(statusCode).json(errObj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @schema Error
|
||||
* title: "Response Error"
|
||||
* type: object
|
||||
* properties:
|
||||
* code:
|
||||
* type: string
|
||||
* description: A slug code to indicate the type of the error.
|
||||
* enum: [invalid_state_error, invalid_request_error, api_error, unknown_error]
|
||||
* message:
|
||||
* type: string
|
||||
* description: Description of the error that occurred.
|
||||
* example: "first_name must be a string"
|
||||
* type:
|
||||
* type: string
|
||||
* description: A slug indicating the type of the error.
|
||||
* enum: [QueryRunnerAlreadyReleasedError, TransactionAlreadyStartedError, TransactionNotStartedError, conflict, unauthorized, payment_authorization_error, duplicate_error, not_allowed, invalid_data, not_found, database_error, unexpected_state, invalid_argument, unknown_error]
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
export enum PostgresError {
|
||||
DUPLICATE_ERROR = "23505",
|
||||
FOREIGN_KEY_ERROR = "23503",
|
||||
SERIALIZATION_FAILURE = "40001",
|
||||
NULL_VIOLATION = "23502",
|
||||
}
|
||||
|
||||
export const formatException = (err): MedusaError => {
|
||||
switch (err.code) {
|
||||
case PostgresError.DUPLICATE_ERROR:
|
||||
return new MedusaError(
|
||||
MedusaError.Types.DUPLICATE_ERROR,
|
||||
`${err.table.charAt(0).toUpperCase()}${err.table.slice(
|
||||
1
|
||||
)} with ${err.detail.slice(4).replace(/[()=]/g, (s) => {
|
||||
return s === "=" ? " " : ""
|
||||
})}`
|
||||
)
|
||||
case PostgresError.FOREIGN_KEY_ERROR: {
|
||||
const matches =
|
||||
/Key \(([\w-\d]+)\)=\(([\w-\d]+)\) is not present in table "(\w+)"/g.exec(
|
||||
err.detail
|
||||
)
|
||||
|
||||
if (matches?.length !== 4) {
|
||||
return new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
JSON.stringify(matches)
|
||||
)
|
||||
}
|
||||
|
||||
return new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`${matches[3]?.charAt(0).toUpperCase()}${matches[3]?.slice(1)} with ${
|
||||
matches[1]
|
||||
} ${matches[2]} does not exist.`
|
||||
)
|
||||
}
|
||||
case PostgresError.SERIALIZATION_FAILURE: {
|
||||
return new MedusaError(
|
||||
MedusaError.Types.CONFLICT,
|
||||
err?.detail ?? err?.message
|
||||
)
|
||||
}
|
||||
case PostgresError.NULL_VIOLATION: {
|
||||
return new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Can't insert null value in field ${err?.column} on insert in table ${err?.table}`
|
||||
)
|
||||
}
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
3
packages/core/framework/src/http/middlewares/index.ts
Normal file
3
packages/core/framework/src/http/middlewares/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./authenticate-middleware"
|
||||
export * from "./error-handler"
|
||||
export * from "./exception-formatter"
|
||||
957
packages/core/framework/src/http/router.ts
Normal file
957
packages/core/framework/src/http/router.ts
Normal file
@@ -0,0 +1,957 @@
|
||||
import { parseCorsOrigins, promiseAll, wrapHandler } from "@medusajs/utils"
|
||||
import cors from "cors"
|
||||
import {
|
||||
type Express,
|
||||
json,
|
||||
RequestHandler,
|
||||
Router,
|
||||
text,
|
||||
urlencoded,
|
||||
} from "express"
|
||||
import { readdir } from "fs/promises"
|
||||
import { extname, join, parse, sep } from "path"
|
||||
import { configManager } from "../config"
|
||||
import { logger } from "../logger"
|
||||
import { authenticate, AuthType, errorHandler } from "./middlewares"
|
||||
import {
|
||||
GlobalMiddlewareDescriptor,
|
||||
HTTP_METHODS,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
MiddlewareFunction,
|
||||
MiddlewareRoute,
|
||||
MiddlewaresConfig,
|
||||
MiddlewareVerb,
|
||||
ParserConfigArgs,
|
||||
RouteConfig,
|
||||
RouteDescriptor,
|
||||
RouteHandler,
|
||||
RouteVerb,
|
||||
} from "./types"
|
||||
|
||||
const log = ({
|
||||
activityId,
|
||||
message,
|
||||
}: {
|
||||
activityId?: string
|
||||
message: string
|
||||
}) => {
|
||||
if (activityId) {
|
||||
logger.progress(activityId, message)
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* File name that is used to indicate that the file is a route file
|
||||
*/
|
||||
const ROUTE_NAME = "route"
|
||||
|
||||
/**
|
||||
* Flag that developers can export from their route files to indicate
|
||||
* whether or not the route should be authenticated or not.
|
||||
*/
|
||||
const AUTHTHENTICATE = "AUTHENTICATE"
|
||||
|
||||
/**
|
||||
* File name for the global middlewares file
|
||||
*/
|
||||
const MIDDLEWARES_NAME = "middlewares"
|
||||
|
||||
const pathSegmentReplacer = {
|
||||
"\\[\\.\\.\\.\\]": () => `*`,
|
||||
"\\[(\\w+)?": (param?: string) => `:${param}`,
|
||||
"\\]": () => ``,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param routes - The routes to prioritize
|
||||
*
|
||||
* @return An array of sorted
|
||||
* routes based on their priority
|
||||
*/
|
||||
const prioritize = (routes: RouteDescriptor[]): RouteDescriptor[] => {
|
||||
return routes.sort((a, b) => {
|
||||
return a.priority - b.priority
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The smaller the number the higher the priority with zero indicating
|
||||
* highest priority
|
||||
*
|
||||
* @param path - The path to calculate the priority for
|
||||
*
|
||||
* @return An integer ranging from `0` to `Infinity`
|
||||
*/
|
||||
function calculatePriority(path: string): number {
|
||||
const depth = path.match(/\/.+?/g)?.length || 0
|
||||
const specifity = path.match(/\/:.+?/g)?.length || 0
|
||||
const catchall = (path.match(/\/\*/g)?.length || 0) > 0 ? Infinity : 0
|
||||
|
||||
return depth + specifity + catchall
|
||||
}
|
||||
|
||||
function matchMethod(
|
||||
method: RouteVerb,
|
||||
configMethod: MiddlewareRoute["method"]
|
||||
): boolean {
|
||||
if (!configMethod || configMethod === "USE" || configMethod === "ALL") {
|
||||
return true
|
||||
} else if (Array.isArray(configMethod)) {
|
||||
return (
|
||||
configMethod.includes(method) ||
|
||||
configMethod.includes("ALL") ||
|
||||
configMethod.includes("USE")
|
||||
)
|
||||
} else {
|
||||
return method === configMethod
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that looks though the global middlewares and returns the first
|
||||
* complete match for the given path and method.
|
||||
*
|
||||
* @param path - The path to match
|
||||
* @param method - The method to match
|
||||
* @param routes - The routes to match against
|
||||
* @returns The first complete match or undefined if no match is found
|
||||
*/
|
||||
function findMatch(
|
||||
path: string,
|
||||
method: RouteVerb,
|
||||
routes: MiddlewareRoute[]
|
||||
): MiddlewareRoute | undefined {
|
||||
for (const route of routes) {
|
||||
const { matcher, method: configMethod } = route
|
||||
|
||||
if (matchMethod(method, configMethod)) {
|
||||
let isMatch = false
|
||||
|
||||
if (typeof matcher === "string") {
|
||||
// Convert wildcard expressions to proper regex for matching entire path
|
||||
// The '.*' will match any character sequence including '/'
|
||||
const regex = new RegExp(`^${matcher.split("*").join(".*")}$`)
|
||||
isMatch = regex.test(path)
|
||||
} else if (matcher instanceof RegExp) {
|
||||
// Ensure that the regex matches the entire path
|
||||
const match = path.match(matcher)
|
||||
isMatch = match !== null && match[0] === path
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
return route // Return the first complete match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined // Return undefined if no complete match is found
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of body parser middlewares that are applied on routes
|
||||
* out-of-the-box.
|
||||
*/
|
||||
function getBodyParserMiddleware(args?: ParserConfigArgs) {
|
||||
const sizeLimit = args?.sizeLimit
|
||||
const preserveRawBody = args?.preserveRawBody
|
||||
return [
|
||||
json({
|
||||
limit: sizeLimit,
|
||||
verify: preserveRawBody
|
||||
? (req: MedusaRequest, res: MedusaResponse, buf: Buffer) => {
|
||||
req.rawBody = buf
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
text({ limit: sizeLimit }),
|
||||
urlencoded({ limit: sizeLimit, extended: true }),
|
||||
]
|
||||
}
|
||||
|
||||
// TODO this router would need a proper rework, but it is out of scope right now
|
||||
|
||||
class ApiRoutesLoader {
|
||||
/**
|
||||
* Map of router path and its descriptor
|
||||
* @private
|
||||
*/
|
||||
#routesMap = new Map<string, RouteDescriptor>()
|
||||
|
||||
/**
|
||||
* Global middleware descriptors
|
||||
* @private
|
||||
*/
|
||||
#globalMiddlewaresDescriptor: GlobalMiddlewareDescriptor | undefined
|
||||
|
||||
/**
|
||||
* An express instance
|
||||
* @private
|
||||
*/
|
||||
readonly #app: Express
|
||||
|
||||
/**
|
||||
* A router to assign the route to
|
||||
* @private
|
||||
*/
|
||||
readonly #router: Router
|
||||
|
||||
/**
|
||||
* An eventual activity id for information tracking
|
||||
* @private
|
||||
*/
|
||||
readonly #activityId?: string
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the routes scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/\.DS_Store/,
|
||||
/(\.ts\.map|\.js\.map|\.d\.ts|\.md)/,
|
||||
/^_[^/\\]*(\.[^/\\]+)?$/,
|
||||
]
|
||||
|
||||
/**
|
||||
* Path from where to load the routes from
|
||||
* @private
|
||||
*/
|
||||
readonly #sourceDir: string
|
||||
|
||||
/**
|
||||
* Wrap the original route handler implementation for
|
||||
* instrumentation.
|
||||
*/
|
||||
static traceRoute?: (
|
||||
handler: RouteHandler,
|
||||
route: { route: string; method: string }
|
||||
) => RouteHandler
|
||||
|
||||
/**
|
||||
* Wrap the original middleware handler implementation for
|
||||
* instrumentation.
|
||||
*/
|
||||
static traceMiddleware?: (
|
||||
handler: RequestHandler | MiddlewareFunction,
|
||||
route: { route: string; method?: string }
|
||||
) => RequestHandler
|
||||
|
||||
constructor({
|
||||
app,
|
||||
activityId,
|
||||
sourceDir,
|
||||
}: {
|
||||
app: Express
|
||||
activityId?: string
|
||||
sourceDir: string
|
||||
}) {
|
||||
this.#app = app
|
||||
this.#router = Router()
|
||||
this.#activityId = activityId
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the route config and display a log info if
|
||||
* it should be ignored or skipped.
|
||||
*
|
||||
* @param {GlobalMiddlewareDescriptor} descriptor
|
||||
* @param {MiddlewaresConfig} config
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
protected validateMiddlewaresConfig({
|
||||
config,
|
||||
}: {
|
||||
config?: MiddlewaresConfig
|
||||
}): void {
|
||||
if (!config?.routes && !config?.errorHandler) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Empty middleware config. Skipping middleware application.`,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (const route of config.routes ?? []) {
|
||||
if (!route.matcher) {
|
||||
throw new Error(
|
||||
`Route is missing a \`matcher\` field. The 'matcher' field is required when applying middleware to this route.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take care of replacing the special path segments
|
||||
* to an express specific path segment
|
||||
*
|
||||
* @param route - The route to parse
|
||||
*
|
||||
* @example
|
||||
* "/admin/orders/[id]/route.ts => "/admin/orders/:id/route.ts"
|
||||
*/
|
||||
protected parseRoute(route: string): string {
|
||||
let route_ = route
|
||||
|
||||
for (const config of Object.entries(pathSegmentReplacer)) {
|
||||
const [searchFor, replacedByFn] = config
|
||||
const replacer = new RegExp(searchFor, "g")
|
||||
|
||||
const matches = [...route_.matchAll(replacer)]
|
||||
|
||||
const parameters = new Set()
|
||||
|
||||
for (const match of matches) {
|
||||
if (match?.[1] && !Number.isInteger(match?.[1])) {
|
||||
if (parameters.has(match?.[1])) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Duplicate parameters found in route ${route} (${match?.[1]})`,
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
`Duplicate parameters found in route ${route} (${match?.[1]}). Make sure that all parameters are unique.`
|
||||
)
|
||||
}
|
||||
|
||||
parameters.add(match?.[1])
|
||||
}
|
||||
|
||||
route_ = route_.replace(match[0], replacedByFn(match?.[1]))
|
||||
}
|
||||
|
||||
const extension = extname(route_)
|
||||
if (extension) {
|
||||
route_ = route_.replace(extension, "")
|
||||
}
|
||||
}
|
||||
|
||||
route = route_
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the file content from a descriptor and retrieve the verbs and handlers
|
||||
* to be assigned to the descriptor
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
protected async createRoutesConfig(): Promise<void> {
|
||||
await promiseAll(
|
||||
[...this.#routesMap.values()].map(async (descriptor: RouteDescriptor) => {
|
||||
const absolutePath = descriptor.absolutePath
|
||||
const route = descriptor.route
|
||||
|
||||
return await import(absolutePath).then((import_) => {
|
||||
const map = this.#routesMap
|
||||
|
||||
const config: RouteConfig = {
|
||||
routes: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* If the developer has not exported the
|
||||
* AUTHENTICATE flag we default to true.
|
||||
*/
|
||||
const shouldRequireAuth =
|
||||
import_[AUTHTHENTICATE] !== undefined
|
||||
? (import_[AUTHTHENTICATE] as boolean)
|
||||
: true
|
||||
|
||||
config.optedOutOfAuth = !shouldRequireAuth
|
||||
/**
|
||||
* If the developer has not exported the
|
||||
* CORS flag we default to true.
|
||||
*/
|
||||
const shouldAddCors =
|
||||
import_["CORS"] !== undefined ? (import_["CORS"] as boolean) : true
|
||||
|
||||
if (route.startsWith("/admin")) {
|
||||
config.routeType = "admin"
|
||||
if (shouldAddCors) {
|
||||
config.shouldAppendAdminCors = true
|
||||
}
|
||||
}
|
||||
|
||||
if (route.startsWith("/store")) {
|
||||
config.routeType = "store"
|
||||
if (shouldAddCors) {
|
||||
config.shouldAppendStoreCors = true
|
||||
}
|
||||
}
|
||||
|
||||
if (route.startsWith("/auth") && shouldAddCors) {
|
||||
config.routeType = "auth"
|
||||
if (shouldAddCors) {
|
||||
config.shouldAppendAuthCors = true
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = Object.keys(import_).filter((key) => {
|
||||
/**
|
||||
* Filter out any export that is not a function
|
||||
*/
|
||||
return typeof import_[key] === "function"
|
||||
})
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (HTTP_METHODS.includes(handler as RouteVerb)) {
|
||||
config.routes?.push({
|
||||
method: handler as RouteVerb,
|
||||
handler: import_[handler],
|
||||
})
|
||||
} else {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Skipping handler ${handler} in ${absolutePath}. Invalid HTTP method: ${handler}.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.routes?.length) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `No valid route handlers detected in ${absolutePath}. Skipping route configuration.`,
|
||||
})
|
||||
|
||||
map.delete(absolutePath)
|
||||
return
|
||||
}
|
||||
|
||||
descriptor.config = config
|
||||
map.set(absolutePath, descriptor)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
protected createRoutesDescriptor(path: string) {
|
||||
const descriptor: RouteDescriptor = {
|
||||
absolutePath: path,
|
||||
relativePath: path,
|
||||
route: "",
|
||||
priority: Infinity,
|
||||
}
|
||||
|
||||
const childPath = path.replace(this.#sourceDir, "")
|
||||
descriptor.relativePath = childPath
|
||||
|
||||
let routeToParse = childPath
|
||||
|
||||
const pathSegments = routeToParse.split(sep)
|
||||
const lastSegment = pathSegments[pathSegments.length - 1]
|
||||
|
||||
if (lastSegment.startsWith("route")) {
|
||||
pathSegments.pop()
|
||||
routeToParse = pathSegments.join("/")
|
||||
}
|
||||
|
||||
descriptor.route = this.parseRoute(routeToParse)
|
||||
descriptor.priority = calculatePriority(descriptor.route)
|
||||
|
||||
this.#routesMap.set(path, descriptor)
|
||||
}
|
||||
|
||||
protected async createMiddlewaresDescriptor() {
|
||||
const filePaths = await readdir(this.#sourceDir)
|
||||
|
||||
const filteredFilePaths = filePaths.filter((path) => {
|
||||
const pathToCheck = path.replace(this.#sourceDir, "")
|
||||
return !pathToCheck
|
||||
.split(sep)
|
||||
.some((segment) =>
|
||||
this.#excludes.some((exclude) => exclude.test(segment))
|
||||
)
|
||||
})
|
||||
|
||||
const middlewareFilePath = filteredFilePaths.find((file) => {
|
||||
return file.replace(/\.[^/.]+$/, "") === MIDDLEWARES_NAME
|
||||
})
|
||||
|
||||
if (!middlewareFilePath) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `No middleware files found in ${
|
||||
this.#sourceDir
|
||||
}. Skipping middleware configuration.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const absolutePath = join(this.#sourceDir, middlewareFilePath)
|
||||
|
||||
await import(absolutePath).then((import_) => {
|
||||
const middlewaresConfig = import_.default as MiddlewaresConfig | undefined
|
||||
|
||||
if (!middlewaresConfig) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `No middleware configuration found in ${absolutePath}. Skipping middleware configuration.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
middlewaresConfig.routes = middlewaresConfig.routes?.map((route) => {
|
||||
return {
|
||||
...route,
|
||||
method: route.method ?? "USE",
|
||||
}
|
||||
})
|
||||
|
||||
const descriptor: GlobalMiddlewareDescriptor = {
|
||||
config: middlewaresConfig,
|
||||
}
|
||||
|
||||
this.validateMiddlewaresConfig(descriptor)
|
||||
|
||||
this.#globalMiddlewaresDescriptor = descriptor
|
||||
})
|
||||
}
|
||||
|
||||
protected async createRoutesMap(): Promise<void> {
|
||||
await promiseAll(
|
||||
await readdir(this.#sourceDir, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
}).then((entries) => {
|
||||
const fileEntries = entries.filter((entry) => {
|
||||
const fullPathFromSource = join(entry.path, entry.name).replace(
|
||||
this.#sourceDir,
|
||||
""
|
||||
)
|
||||
const isExcluded = fullPathFromSource
|
||||
.split(sep)
|
||||
.some((segment) =>
|
||||
this.#excludes.some((exclude) => exclude.test(segment))
|
||||
)
|
||||
|
||||
return (
|
||||
!entry.isDirectory() &&
|
||||
!isExcluded &&
|
||||
parse(entry.name).name === ROUTE_NAME
|
||||
)
|
||||
})
|
||||
|
||||
return fileEntries.map(async (entry) => {
|
||||
const path = join(entry.path, entry.name)
|
||||
return this.createRoutesDescriptor(path)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the most specific body parser middleware to the router
|
||||
*/
|
||||
applyBodyParserMiddleware(path: string, method: RouteVerb): void {
|
||||
const middlewareDescriptor = this.#globalMiddlewaresDescriptor
|
||||
|
||||
const mostSpecificConfig = findMatch(
|
||||
path,
|
||||
method,
|
||||
middlewareDescriptor?.config?.routes ?? []
|
||||
)
|
||||
|
||||
if (!mostSpecificConfig || mostSpecificConfig?.bodyParser === undefined) {
|
||||
this.#router[method.toLowerCase()](path, ...getBodyParserMiddleware())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (mostSpecificConfig?.bodyParser) {
|
||||
this.#router[method.toLowerCase()](
|
||||
path,
|
||||
...getBodyParserMiddleware(mostSpecificConfig?.bodyParser)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the route middleware on a route. Encapsulates the logic
|
||||
* needed to pass the middleware via the trace calls
|
||||
*/
|
||||
applyAuthMiddleware(
|
||||
route: string,
|
||||
actorType: string | string[],
|
||||
authType: AuthType | AuthType[],
|
||||
options?: { allowUnauthenticated?: boolean; allowUnregistered?: boolean }
|
||||
) {
|
||||
let authenticateMiddleware = authenticate(actorType, authType, options)
|
||||
if (ApiRoutesLoader.traceMiddleware) {
|
||||
authenticateMiddleware = ApiRoutesLoader.traceMiddleware(
|
||||
authenticateMiddleware,
|
||||
{ route: route }
|
||||
)
|
||||
}
|
||||
|
||||
this.#router.use(route, authenticateMiddleware)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the route specific middlewares to the router,
|
||||
* this includes the cors, authentication and
|
||||
* body parsing. These are applied first to ensure
|
||||
* that they are applied before any other middleware.
|
||||
*/
|
||||
applyRouteSpecificMiddlewares(): void {
|
||||
const prioritizedRoutes = prioritize([...this.#routesMap.values()])
|
||||
|
||||
for (const descriptor of prioritizedRoutes) {
|
||||
if (!descriptor.config?.routes?.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const config = descriptor.config
|
||||
const routes = descriptor.config.routes
|
||||
|
||||
/**
|
||||
* Apply default store and admin middlewares if
|
||||
* not opted out of.
|
||||
*/
|
||||
|
||||
if (config.shouldAppendAdminCors) {
|
||||
/**
|
||||
* Apply the admin cors
|
||||
*/
|
||||
this.#router.use(
|
||||
descriptor.route,
|
||||
cors({
|
||||
origin: parseCorsOrigins(
|
||||
configManager.config.projectConfig.http.adminCors
|
||||
),
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (config.shouldAppendAuthCors) {
|
||||
/**
|
||||
* Apply the auth cors
|
||||
*/
|
||||
this.#router.use(
|
||||
descriptor.route,
|
||||
cors({
|
||||
origin: parseCorsOrigins(
|
||||
configManager.config.projectConfig.http.authCors
|
||||
),
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (config.shouldAppendStoreCors) {
|
||||
/**
|
||||
* Apply the store cors
|
||||
*/
|
||||
this.#router.use(
|
||||
descriptor.route,
|
||||
cors({
|
||||
origin: parseCorsOrigins(
|
||||
configManager.config.projectConfig.http.storeCors
|
||||
),
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// We only apply the auth middleware to store routes to populate the auth context. For actual authentication, users can just reapply the middleware.
|
||||
if (!config.optedOutOfAuth && config.routeType === "store") {
|
||||
this.applyAuthMiddleware(
|
||||
descriptor.route,
|
||||
"customer",
|
||||
["bearer", "session"],
|
||||
{
|
||||
allowUnauthenticated: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!config.optedOutOfAuth && config.routeType === "admin") {
|
||||
this.applyAuthMiddleware(descriptor.route, "user", [
|
||||
"bearer",
|
||||
"session",
|
||||
"api-key",
|
||||
])
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
/**
|
||||
* Apply the body parser middleware if the route
|
||||
* has not opted out of it.
|
||||
*/
|
||||
this.applyBodyParserMiddleware(descriptor.route, route.method!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the error handler middleware to the router
|
||||
*/
|
||||
applyErrorHandlerMiddleware(): void {
|
||||
const middlewareDescriptor = this.#globalMiddlewaresDescriptor
|
||||
const errorHandlerFn = middlewareDescriptor?.config?.errorHandler
|
||||
|
||||
/**
|
||||
* If the user has opted out of the error handler then return
|
||||
*/
|
||||
if (errorHandlerFn === false) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user has provided a custom error handler then use it
|
||||
*/
|
||||
if (errorHandlerFn) {
|
||||
this.#router.use(errorHandlerFn as any)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user has not provided a custom error handler then use the
|
||||
* default one.
|
||||
*/
|
||||
this.#router.use(errorHandler() as any)
|
||||
}
|
||||
|
||||
protected async registerRoutes(): Promise<void> {
|
||||
const middlewareDescriptor = this.#globalMiddlewaresDescriptor
|
||||
|
||||
const shouldWrapHandler = middlewareDescriptor?.config
|
||||
? middlewareDescriptor.config.errorHandler !== false
|
||||
: true
|
||||
|
||||
const prioritizedRoutes = prioritize([...this.#routesMap.values()])
|
||||
|
||||
for (const descriptor of prioritizedRoutes) {
|
||||
if (!descriptor.config?.routes?.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const routes = descriptor.config.routes
|
||||
|
||||
for (const route of routes) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Registering route [${route.method?.toUpperCase()}] - ${
|
||||
descriptor.route
|
||||
}`,
|
||||
})
|
||||
|
||||
let handler: RequestHandler | RouteHandler = route.handler
|
||||
|
||||
/**
|
||||
* Give handler to the trace route handler for instrumentation
|
||||
* from outside-in.
|
||||
*/
|
||||
if (ApiRoutesLoader.traceRoute) {
|
||||
handler = ApiRoutesLoader.traceRoute(handler, {
|
||||
method: route.method!,
|
||||
route: descriptor.route,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user hasn't opted out of error handling then
|
||||
* we wrap the handler in a try/catch block.
|
||||
*/
|
||||
if (shouldWrapHandler) {
|
||||
handler = wrapHandler(handler as Parameters<typeof wrapHandler>[0])
|
||||
}
|
||||
|
||||
this.#router[route.method!.toLowerCase()](descriptor.route, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async registerMiddlewares(): Promise<void> {
|
||||
const descriptor = this.#globalMiddlewaresDescriptor
|
||||
|
||||
if (!descriptor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!descriptor.config?.routes?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const routes = descriptor.config.routes
|
||||
|
||||
/**
|
||||
* We don't prioritize the middlewares to preserve the order
|
||||
* in which they are defined in the 'middlewares.ts'. This is to
|
||||
* maintain the same behavior as how middleware is applied
|
||||
* in Express.
|
||||
*/
|
||||
|
||||
for (const route of routes) {
|
||||
if (!route.middlewares || !route.middlewares.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const methods = (
|
||||
Array.isArray(route.method) ? route.method : [route.method]
|
||||
).filter(Boolean) as MiddlewareVerb[]
|
||||
|
||||
for (const method of methods) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Registering middleware [${method}] - ${route.matcher}`,
|
||||
})
|
||||
|
||||
let middlewares = route.middlewares
|
||||
if (ApiRoutesLoader.traceMiddleware) {
|
||||
middlewares = middlewares.map((middleware) =>
|
||||
ApiRoutesLoader.traceMiddleware!(middleware, {
|
||||
route: String(route.matcher),
|
||||
method,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
this.#router[method.toLowerCase()](route.matcher, ...middlewares)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
performance && performance.mark("file-base-routing-start" + this.#sourceDir)
|
||||
|
||||
let apiExists = true
|
||||
|
||||
/**
|
||||
* Since the file based routing does not require a index file
|
||||
* we can't check if it exists using require. Instead we try
|
||||
* to read the directory and if it fails we know that the
|
||||
* directory does not exist.
|
||||
*/
|
||||
try {
|
||||
await readdir(this.#sourceDir)
|
||||
} catch (_error) {
|
||||
apiExists = false
|
||||
}
|
||||
|
||||
if (apiExists) {
|
||||
await this.createMiddlewaresDescriptor()
|
||||
|
||||
await this.createRoutesMap()
|
||||
await this.createRoutesConfig()
|
||||
|
||||
this.applyRouteSpecificMiddlewares()
|
||||
|
||||
await this.registerMiddlewares()
|
||||
await this.registerRoutes()
|
||||
|
||||
this.applyErrorHandlerMiddleware()
|
||||
|
||||
/**
|
||||
* Apply the router to the app.
|
||||
*
|
||||
* This prevents middleware from a plugin from
|
||||
* bleeding into the global middleware stack.
|
||||
*/
|
||||
this.#app.use("/", this.#router)
|
||||
}
|
||||
|
||||
performance && performance.mark("file-base-routing-end" + this.#sourceDir)
|
||||
const timeSpent =
|
||||
performance &&
|
||||
performance
|
||||
.measure(
|
||||
"file-base-routing-measure" + this.#sourceDir,
|
||||
"file-base-routing-start" + this.#sourceDir,
|
||||
"file-base-routing-end" + this.#sourceDir
|
||||
)
|
||||
?.duration?.toFixed(2)
|
||||
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Routes loaded in ${timeSpent} ms`,
|
||||
})
|
||||
|
||||
this.#routesMap.clear()
|
||||
this.#globalMiddlewaresDescriptor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class RoutesLoader {
|
||||
/**
|
||||
* An express instance
|
||||
* @private
|
||||
*/
|
||||
readonly #app: Express
|
||||
|
||||
/**
|
||||
* An eventual activity id for information tracking
|
||||
* @private
|
||||
*/
|
||||
readonly #activityId?: string
|
||||
|
||||
/**
|
||||
* Path from where to load the routes from
|
||||
* @private
|
||||
*/
|
||||
readonly #sourceDir: string | string[]
|
||||
|
||||
static instrument: {
|
||||
/**
|
||||
* Instrument middleware function calls by wrapping the original
|
||||
* middleware handler inside a custom implementation
|
||||
*/
|
||||
middleware: (callback: (typeof ApiRoutesLoader)["traceMiddleware"]) => void
|
||||
|
||||
/**
|
||||
* Instrument route handler function calls by wrapping the original
|
||||
* middleware handler inside a custom implementation
|
||||
*/
|
||||
route: (callback: (typeof ApiRoutesLoader)["traceRoute"]) => void
|
||||
} = {
|
||||
middleware(callback) {
|
||||
ApiRoutesLoader.traceMiddleware = callback
|
||||
},
|
||||
route(callback) {
|
||||
ApiRoutesLoader.traceRoute = callback
|
||||
},
|
||||
}
|
||||
|
||||
constructor({
|
||||
app,
|
||||
activityId,
|
||||
sourceDir,
|
||||
}: {
|
||||
app: Express
|
||||
activityId?: string
|
||||
sourceDir: string | string[]
|
||||
}) {
|
||||
this.#app = app
|
||||
this.#activityId = activityId
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
async load() {
|
||||
const normalizedSourcePath = Array.isArray(this.#sourceDir)
|
||||
? this.#sourceDir
|
||||
: [this.#sourceDir]
|
||||
|
||||
const promises = normalizedSourcePath.map(async (sourcePath) => {
|
||||
const apiRoutesLoader = new ApiRoutesLoader({
|
||||
app: this.#app,
|
||||
activityId: this.#activityId,
|
||||
sourceDir: sourcePath,
|
||||
})
|
||||
|
||||
await apiRoutesLoader.load()
|
||||
})
|
||||
|
||||
await promiseAll(promises)
|
||||
}
|
||||
}
|
||||
189
packages/core/framework/src/http/types.ts
Normal file
189
packages/core/framework/src/http/types.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { NextFunction, Request, Response } from "express"
|
||||
import { ZodObject } from "zod"
|
||||
|
||||
import { MedusaPricingContext, RequestQueryFields } from "@medusajs/types"
|
||||
import * as core from "express-serve-static-core"
|
||||
import { MedusaContainer } from "../container"
|
||||
|
||||
export interface FindConfig<Entity> {
|
||||
select?: (keyof Entity)[]
|
||||
skip?: number
|
||||
take?: number
|
||||
relations?: string[]
|
||||
order?: { [K: string]: "ASC" | "DESC" }
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all the supported HTTP methods
|
||||
*/
|
||||
export const HTTP_METHODS = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"HEAD",
|
||||
] as const
|
||||
|
||||
export type RouteVerb = (typeof HTTP_METHODS)[number]
|
||||
export type MiddlewareVerb = "USE" | "ALL" | RouteVerb
|
||||
|
||||
type SyncRouteHandler = (req: MedusaRequest, res: MedusaResponse) => void
|
||||
|
||||
export type AsyncRouteHandler = (
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) => Promise<void>
|
||||
|
||||
export type RouteHandler = SyncRouteHandler | AsyncRouteHandler
|
||||
|
||||
export type RouteImplementation = {
|
||||
method?: RouteVerb
|
||||
handler: RouteHandler
|
||||
}
|
||||
|
||||
export type RouteConfig = {
|
||||
optedOutOfAuth?: boolean
|
||||
routeType?: "admin" | "store" | "auth"
|
||||
shouldAppendAdminCors?: boolean
|
||||
shouldAppendStoreCors?: boolean
|
||||
shouldAppendAuthCors?: boolean
|
||||
routes?: RouteImplementation[]
|
||||
}
|
||||
|
||||
export type MiddlewareFunction =
|
||||
| MedusaRequestHandler
|
||||
| ((...args: any[]) => any)
|
||||
|
||||
export type MedusaErrorHandlerFunction = (
|
||||
error: any,
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse,
|
||||
next: MedusaNextFunction
|
||||
) => Promise<void> | void
|
||||
|
||||
export type ParserConfigArgs = {
|
||||
sizeLimit?: string | number | undefined
|
||||
preserveRawBody?: boolean
|
||||
}
|
||||
|
||||
export type ParserConfig = false | ParserConfigArgs
|
||||
|
||||
export type MiddlewareRoute = {
|
||||
method?: MiddlewareVerb | MiddlewareVerb[]
|
||||
matcher: string | RegExp
|
||||
bodyParser?: ParserConfig
|
||||
middlewares?: MiddlewareFunction[]
|
||||
}
|
||||
|
||||
export type MiddlewaresConfig = {
|
||||
errorHandler?: false | MedusaErrorHandlerFunction
|
||||
routes?: MiddlewareRoute[]
|
||||
}
|
||||
|
||||
export type RouteDescriptor = {
|
||||
absolutePath: string
|
||||
relativePath: string
|
||||
route: string
|
||||
priority: number
|
||||
config?: RouteConfig
|
||||
}
|
||||
|
||||
export type GlobalMiddlewareDescriptor = {
|
||||
config?: MiddlewaresConfig
|
||||
}
|
||||
|
||||
export interface MedusaRequest<Body = unknown>
|
||||
extends Request<core.ParamsDictionary, any, Body> {
|
||||
validatedBody: Body
|
||||
validatedQuery: RequestQueryFields & Record<string, unknown>
|
||||
/**
|
||||
* TODO: shouldn't this correspond to returnable fields instead of allowed fields? also it is used by the cleanResponseData util
|
||||
*/
|
||||
allowedProperties: string[]
|
||||
/**
|
||||
* An object containing the select, relation, skip, take and order to be used with medusa internal services
|
||||
*/
|
||||
listConfig: FindConfig<unknown>
|
||||
/**
|
||||
* An object containing the select, relation to be used with medusa internal services
|
||||
*/
|
||||
retrieveConfig: FindConfig<unknown>
|
||||
/**
|
||||
* An object containing fields and variables to be used with the remoteQuery
|
||||
*/
|
||||
remoteQueryConfig: {
|
||||
fields: string[]
|
||||
pagination: { order?: Record<string, string>; skip?: number; take?: number }
|
||||
}
|
||||
/**
|
||||
* An object containing the fields that are filterable e.g `{ id: Any<String> }`
|
||||
*/
|
||||
filterableFields: Record<string, unknown>
|
||||
includes?: Record<string, boolean>
|
||||
/**
|
||||
* An array of fields and relations that are allowed to be queried, this can be set by the
|
||||
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields
|
||||
* @deprecated use `allowed` instead
|
||||
*/
|
||||
allowedFields?: string[]
|
||||
/**
|
||||
* An array of fields and relations that are allowed to be queried, this can be set by the
|
||||
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields set
|
||||
* by the api
|
||||
*/
|
||||
allowed?: string[]
|
||||
errors: string[]
|
||||
scope: MedusaContainer
|
||||
session?: any
|
||||
rawBody?: any
|
||||
requestId?: string
|
||||
/**
|
||||
* An object that carries the context that is used to calculate prices for variants
|
||||
*/
|
||||
pricingContext?: MedusaPricingContext
|
||||
/**
|
||||
* A generic context object that can be used across the request lifecycle
|
||||
*/
|
||||
context?: Record<string, any>
|
||||
|
||||
/**
|
||||
* Custom validator to validate the `additional_data` property in
|
||||
* requests that allows for additional_data
|
||||
*/
|
||||
additionalDataValidator?: ZodObject<any, any>
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
actor_id: string
|
||||
actor_type: string
|
||||
auth_identity_id: string
|
||||
app_metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PublishableKeyContext {
|
||||
key: string
|
||||
sales_channel_ids: string[]
|
||||
}
|
||||
|
||||
export interface AuthenticatedMedusaRequest<Body = never>
|
||||
extends MedusaRequest<Body> {
|
||||
auth_context: AuthContext
|
||||
publishable_key_context?: PublishableKeyContext
|
||||
}
|
||||
|
||||
export interface MedusaStoreRequest<Body = never> extends MedusaRequest<Body> {
|
||||
auth_context?: AuthContext
|
||||
publishable_key_context: PublishableKeyContext
|
||||
}
|
||||
|
||||
export type MedusaResponse<Body = unknown> = Response<Body>
|
||||
|
||||
export type MedusaNextFunction = NextFunction
|
||||
|
||||
export type MedusaRequestHandler<Body = unknown, Res = unknown> = (
|
||||
req: MedusaRequest<Body>,
|
||||
res: MedusaResponse<Res>,
|
||||
next: MedusaNextFunction
|
||||
) => Promise<void> | void
|
||||
61
packages/core/framework/src/http/utils/define-middlewares.ts
Normal file
61
packages/core/framework/src/http/utils/define-middlewares.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
MedusaNextFunction,
|
||||
MedusaRequest,
|
||||
MedusaRequestHandler,
|
||||
MedusaResponse,
|
||||
MiddlewaresConfig,
|
||||
MiddlewareVerb,
|
||||
ParserConfig,
|
||||
} from "../types"
|
||||
import zod, { ZodRawShape } from "zod"
|
||||
|
||||
/**
|
||||
* A helper function to configure the routes by defining custom middleware,
|
||||
* bodyparser config and validators to be merged with the pre-existing
|
||||
* route validators.
|
||||
*/
|
||||
export function defineMiddlewares<
|
||||
Route extends {
|
||||
method?: MiddlewareVerb | MiddlewareVerb[]
|
||||
matcher: string | RegExp
|
||||
bodyParser?: ParserConfig
|
||||
additionalDataValidator?: ZodRawShape
|
||||
// eslint-disable-next-line space-before-function-paren
|
||||
middlewares?: (<Req extends MedusaRequest>(
|
||||
req: Req,
|
||||
res: MedusaResponse,
|
||||
next: MedusaNextFunction
|
||||
) => any)[]
|
||||
}
|
||||
>(
|
||||
config:
|
||||
| Route[]
|
||||
| { routes?: Route[]; errorHandler?: MiddlewaresConfig["errorHandler"] }
|
||||
): MiddlewaresConfig {
|
||||
const routes = Array.isArray(config) ? config : config.routes || []
|
||||
const errorHandler = Array.isArray(config) ? undefined : config.errorHandler
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
routes: routes.map((route) => {
|
||||
const { middlewares, additionalDataValidator, ...rest } = route
|
||||
const customMiddleware: MedusaRequestHandler[] = []
|
||||
|
||||
/**
|
||||
* Define a custom validator when a zod schema is provided via
|
||||
* "additionalDataValidator" property
|
||||
*/
|
||||
if (additionalDataValidator) {
|
||||
customMiddleware.push((req, _, next) => {
|
||||
req.additionalDataValidator = zod.object(additionalDataValidator)
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
middlewares: customMiddleware.concat(middlewares || []),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
16
packages/core/framework/src/index.ts
Normal file
16
packages/core/framework/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * from "./config"
|
||||
export * from "./container"
|
||||
export * from "./database"
|
||||
export * from "./feature-flags"
|
||||
export * from "./http"
|
||||
export * from "./jobs"
|
||||
export * from "./links"
|
||||
export * from "./logger"
|
||||
export * from "./medusa-app-loader"
|
||||
export * from "./subscribers"
|
||||
export * from "./workflows"
|
||||
export * from "./telemetry"
|
||||
|
||||
export const MEDUSA_CLI_PATH = require.resolve("@medusajs/medusa-cli")
|
||||
|
||||
export { GraphQLSchema, gqlSchemaToTypes, Query } from "@medusajs/modules-sdk"
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
IDistributedSchedulerStorage,
|
||||
SchedulerOptions,
|
||||
} from "@medusajs/orchestration"
|
||||
|
||||
export class MockSchedulerStorage implements IDistributedSchedulerStorage {
|
||||
async schedule(
|
||||
jobDefinition: string | { jobId: string },
|
||||
schedulerOptions: SchedulerOptions
|
||||
): Promise<void> {
|
||||
return await Promise.resolve()
|
||||
}
|
||||
|
||||
async remove(jobId: string): Promise<void> {
|
||||
return await Promise.resolve()
|
||||
}
|
||||
|
||||
async removeAll(): Promise<void> {
|
||||
return await Promise.resolve()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
|
||||
export default async function handler(container: MedusaContainer) {
|
||||
console.log(`You have received 5 orders today`)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "summarize-orders",
|
||||
schedule: "* * * * * *",
|
||||
numberOfExecutions: 2,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { join } from "path"
|
||||
import { WorkflowManager, WorkflowScheduler } from "@medusajs/orchestration"
|
||||
import { MockSchedulerStorage } from "../__fixtures__/mock-scheduler-storage"
|
||||
import { JobLoader } from "../job-loader"
|
||||
|
||||
describe("register jobs", () => {
|
||||
WorkflowScheduler.setStorage(new MockSchedulerStorage())
|
||||
|
||||
let jobLoader!: JobLoader
|
||||
|
||||
beforeAll(() => {
|
||||
jobLoader = new JobLoader(join(__dirname, "../__fixtures__/plugin/jobs"))
|
||||
})
|
||||
|
||||
it("registers jobs from plugins", async () => {
|
||||
await jobLoader.load()
|
||||
const workflow = WorkflowManager.getWorkflow("job-summarize-orders")
|
||||
expect(workflow).toBeDefined()
|
||||
expect(workflow?.options.schedule).toEqual({
|
||||
cron: "* * * * * *",
|
||||
numberOfExecutions: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
1
packages/core/framework/src/jobs/index.ts
Normal file
1
packages/core/framework/src/jobs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './job-loader'
|
||||
181
packages/core/framework/src/jobs/job-loader.ts
Normal file
181
packages/core/framework/src/jobs/job-loader.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { SchedulerOptions } from "@medusajs/orchestration"
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import { isObject, MedusaError, promiseAll } from "@medusajs/utils"
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
StepResponse,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import { access, readdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { logger } from "../logger"
|
||||
|
||||
type CronJobConfig = {
|
||||
name: string
|
||||
schedule: string
|
||||
numberOfExecutions?: SchedulerOptions["numberOfExecutions"]
|
||||
}
|
||||
|
||||
type CronJobHandler = (container: MedusaContainer) => Promise<any>
|
||||
|
||||
export class JobLoader {
|
||||
/**
|
||||
* The directory from which to load the jobs
|
||||
* @private
|
||||
*/
|
||||
#sourceDir: string | string[]
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the subscriber scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/index\.js/,
|
||||
/index\.ts/,
|
||||
/\.DS_Store/,
|
||||
/(\.ts\.map|\.js\.map|\.d\.ts|\.md)/,
|
||||
/^_[^/\\]*(\.[^/\\]+)?$/,
|
||||
]
|
||||
|
||||
constructor(sourceDir: string | string[]) {
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cron job configuration
|
||||
* @param config
|
||||
* @protected
|
||||
*/
|
||||
protected validateConfig(config: {
|
||||
schedule: string | SchedulerOptions
|
||||
name: string
|
||||
}) {
|
||||
if (!config) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Config is required for scheduled jobs."
|
||||
)
|
||||
}
|
||||
|
||||
if (!config.schedule) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Cron schedule definition is required for scheduled jobs."
|
||||
)
|
||||
}
|
||||
|
||||
if (!config.name) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_ARGUMENT,
|
||||
"Job name is required for scheduled jobs."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow to register a new cron job
|
||||
* @param config
|
||||
* @param handler
|
||||
* @protected
|
||||
*/
|
||||
protected registerJob({
|
||||
config,
|
||||
handler,
|
||||
}: {
|
||||
config: CronJobConfig
|
||||
handler: CronJobHandler
|
||||
}) {
|
||||
const workflowName = `job-${config.name}`
|
||||
const step = createStep(
|
||||
`${config.name}-as-step`,
|
||||
async (_, stepContext) => {
|
||||
const { container } = stepContext
|
||||
try {
|
||||
const res = await handler(container)
|
||||
return new StepResponse(res, res)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Scheduled job ${config.name} failed with error: ${error.message}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const workflowConfig = {
|
||||
name: workflowName,
|
||||
schedule: isObject(config.schedule)
|
||||
? config.schedule
|
||||
: {
|
||||
cron: config.schedule,
|
||||
numberOfExecutions: config.numberOfExecutions,
|
||||
},
|
||||
}
|
||||
|
||||
createWorkflow(workflowConfig, () => {
|
||||
step()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cron jobs from one or multiple source paths
|
||||
*/
|
||||
async load() {
|
||||
const normalizedSourcePath = Array.isArray(this.#sourceDir)
|
||||
? this.#sourceDir
|
||||
: [this.#sourceDir]
|
||||
|
||||
const promises = normalizedSourcePath.map(async (sourcePath) => {
|
||||
try {
|
||||
await access(sourcePath)
|
||||
} catch {
|
||||
logger.info(`No job to load from ${sourcePath}. skipped.`)
|
||||
return
|
||||
}
|
||||
|
||||
return await readdir(sourcePath, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
}).then(async (entries) => {
|
||||
const fileEntries = entries.filter((entry) => {
|
||||
return (
|
||||
!entry.isDirectory() &&
|
||||
!this.#excludes.some((exclude) => exclude.test(entry.name))
|
||||
)
|
||||
})
|
||||
|
||||
logger.debug(`Registering jobs from ${sourcePath}.`)
|
||||
|
||||
return await promiseAll(
|
||||
fileEntries.map(async (entry) => {
|
||||
const fullPath = join(entry.path, entry.name)
|
||||
|
||||
const module_ = await import(fullPath)
|
||||
|
||||
const input = {
|
||||
config: module_.config,
|
||||
handler: module_.default,
|
||||
}
|
||||
|
||||
this.validateConfig(input.config)
|
||||
return input
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const jobsInputs = await promiseAll(promises)
|
||||
const flatJobsInput = jobsInputs.flat(1).filter(
|
||||
(
|
||||
job
|
||||
): job is {
|
||||
config: CronJobConfig
|
||||
handler: CronJobHandler
|
||||
} => !!job
|
||||
)
|
||||
|
||||
flatJobsInput.map(this.registerJob)
|
||||
|
||||
logger.debug(`Job registered.`)
|
||||
}
|
||||
}
|
||||
22
packages/core/framework/src/links/__fixtures__/links/link.ts
Normal file
22
packages/core/framework/src/links/__fixtures__/links/link.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineLink, MedusaService, model, Module } from "@medusajs/utils"
|
||||
|
||||
const model1 = model.define("model-1", {
|
||||
id: model.id().primaryKey(),
|
||||
})
|
||||
|
||||
const model2 = model.define("model-2", {
|
||||
id: model.id().primaryKey(),
|
||||
})
|
||||
|
||||
const module1 = Module("module-1", {
|
||||
service: class Service1 extends MedusaService({ model1 }) {},
|
||||
})
|
||||
|
||||
const module2 = Module("module-2", {
|
||||
service: class Service2 extends MedusaService({ model2 }) {},
|
||||
})
|
||||
|
||||
export const module1And2Link = defineLink(
|
||||
module1.linkable.model1,
|
||||
module2.linkable.model2
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineLink, MedusaService, model, Module } from "@medusajs/utils"
|
||||
|
||||
const model3 = model.define("model-3", {
|
||||
id: model.id().primaryKey(),
|
||||
})
|
||||
|
||||
const model4 = model.define("model-4", {
|
||||
id: model.id().primaryKey(),
|
||||
})
|
||||
|
||||
const module3 = Module("module-3", {
|
||||
service: class Service3 extends MedusaService({ model3 }) {},
|
||||
})
|
||||
|
||||
const module4 = Module("module-4", {
|
||||
service: class Service4 extends MedusaService({ model4 }) {},
|
||||
})
|
||||
|
||||
export const module3And4Link = defineLink(
|
||||
module3.linkable.model3,
|
||||
module4.linkable.model4
|
||||
)
|
||||
19
packages/core/framework/src/links/__tests__/index.spec.ts
Normal file
19
packages/core/framework/src/links/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { join } from "path"
|
||||
import { LinkLoader } from "../link-loader"
|
||||
import { MedusaModule } from "@medusajs/modules-sdk"
|
||||
|
||||
describe("LinkLoader", () => {
|
||||
const rootDir = join(__dirname, "../__fixtures__", "links")
|
||||
|
||||
it("should register each link in the '/links' folder and sub folder", async () => {
|
||||
let links = MedusaModule.getCustomLinks()
|
||||
|
||||
expect(links.length).toBe(0)
|
||||
|
||||
await new LinkLoader(rootDir).load()
|
||||
|
||||
links = MedusaModule.getCustomLinks()
|
||||
|
||||
expect(links.length).toBe(2)
|
||||
})
|
||||
})
|
||||
1
packages/core/framework/src/links/index.ts
Normal file
1
packages/core/framework/src/links/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./link-loader"
|
||||
72
packages/core/framework/src/links/link-loader.ts
Normal file
72
packages/core/framework/src/links/link-loader.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { promiseAll } from "@medusajs/utils"
|
||||
import { logger } from "../logger"
|
||||
import { access, readdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
|
||||
export class LinkLoader {
|
||||
/**
|
||||
* The directory from which to load the links
|
||||
* @private
|
||||
*/
|
||||
#sourceDir: string | string[]
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the subscriber scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/index\.js/,
|
||||
/index\.ts/,
|
||||
/\.DS_Store/,
|
||||
/(\.ts\.map|\.js\.map|\.d\.ts|\.md)/,
|
||||
/^_[^/\\]*(\.[^/\\]+)?$/,
|
||||
]
|
||||
|
||||
constructor(sourceDir: string | string[]) {
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Load links from the source paths, links are registering themselves,
|
||||
* therefore we only need to import them
|
||||
*/
|
||||
async load() {
|
||||
const normalizedSourcePath = Array.isArray(this.#sourceDir)
|
||||
? this.#sourceDir
|
||||
: [this.#sourceDir]
|
||||
|
||||
const promises = normalizedSourcePath.map(async (sourcePath) => {
|
||||
try {
|
||||
await access(sourcePath)
|
||||
} catch {
|
||||
logger.info(`No link to load from ${sourcePath}. skipped.`)
|
||||
return
|
||||
}
|
||||
|
||||
return await readdir(sourcePath, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
}).then(async (entries) => {
|
||||
const fileEntries = entries.filter((entry) => {
|
||||
return (
|
||||
!entry.isDirectory() &&
|
||||
!this.#excludes.some((exclude) => exclude.test(entry.name))
|
||||
)
|
||||
})
|
||||
|
||||
logger.debug(`Registering links from ${sourcePath}.`)
|
||||
|
||||
return await promiseAll(
|
||||
fileEntries.map(async (entry) => {
|
||||
const fullPath = join(entry.path, entry.name)
|
||||
return await import(fullPath)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
await promiseAll(promises)
|
||||
|
||||
logger.debug(`Links registered.`)
|
||||
}
|
||||
}
|
||||
3
packages/core/framework/src/logger/index.ts
Normal file
3
packages/core/framework/src/logger/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import logger from "@medusajs/medusa-cli/dist/reporter"
|
||||
|
||||
export { logger }
|
||||
286
packages/core/framework/src/medusa-app-loader.ts
Normal file
286
packages/core/framework/src/medusa-app-loader.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
MedusaApp,
|
||||
MedusaAppGetLinksExecutionPlanner,
|
||||
MedusaAppMigrateDown,
|
||||
MedusaAppMigrateGenerate,
|
||||
MedusaAppMigrateUp,
|
||||
MedusaAppOutput,
|
||||
ModulesDefinition,
|
||||
RegisterModuleJoinerConfig,
|
||||
} from "@medusajs/modules-sdk"
|
||||
import {
|
||||
CommonTypes,
|
||||
ConfigModule,
|
||||
ILinkMigrationsPlanner,
|
||||
InternalModuleDeclaration,
|
||||
LoadedModule,
|
||||
ModuleDefinition,
|
||||
ModuleServiceInitializeOptions,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
isBoolean,
|
||||
isObject,
|
||||
isPresent,
|
||||
upperCaseFirst,
|
||||
} from "@medusajs/utils"
|
||||
import { pgConnectionLoader } from "./database"
|
||||
|
||||
import { asValue } from "awilix"
|
||||
import { configManager } from "./config"
|
||||
import {
|
||||
container,
|
||||
container as mainContainer,
|
||||
MedusaContainer,
|
||||
} from "./container"
|
||||
|
||||
export class MedusaAppLoader {
|
||||
/**
|
||||
* Container from where to resolve resources
|
||||
* @private
|
||||
*/
|
||||
readonly #container: MedusaContainer
|
||||
|
||||
/**
|
||||
* Extra links modules config which should be added manually to the links to be loaded
|
||||
* @private
|
||||
*/
|
||||
readonly #customLinksModules:
|
||||
| RegisterModuleJoinerConfig
|
||||
| RegisterModuleJoinerConfig[]
|
||||
|
||||
// TODO: Adjust all loaders to accept an optional container such that in test env it is possible if needed to provide a specific container otherwise use the main container
|
||||
// Maybe also adjust the different places to resolve the config from the container instead of the configManager for the same reason
|
||||
// To be discussed
|
||||
constructor({
|
||||
container,
|
||||
customLinksModules,
|
||||
}: {
|
||||
container?: MedusaContainer
|
||||
customLinksModules?:
|
||||
| RegisterModuleJoinerConfig
|
||||
| RegisterModuleJoinerConfig[]
|
||||
} = {}) {
|
||||
this.#container = container ?? mainContainer
|
||||
this.#customLinksModules = customLinksModules ?? []
|
||||
}
|
||||
|
||||
protected mergeDefaultModules(
|
||||
modulesConfig: CommonTypes.ConfigModule["modules"]
|
||||
) {
|
||||
const defaultModules = Object.values(ModulesDefinition).filter(
|
||||
(definition: ModuleDefinition) => {
|
||||
return !!definition.defaultPackage
|
||||
}
|
||||
)
|
||||
|
||||
const configModules = { ...modulesConfig }
|
||||
|
||||
for (const defaultModule of defaultModules as ModuleDefinition[]) {
|
||||
configModules[defaultModule.key] ??=
|
||||
defaultModule.defaultModuleDeclaration
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(
|
||||
configModules as Record<string, InternalModuleDeclaration>
|
||||
)) {
|
||||
const def = {} as ModuleDefinition
|
||||
def.key ??= key
|
||||
def.label ??= ModulesDefinition[key]?.label ?? upperCaseFirst(key)
|
||||
def.isQueryable = ModulesDefinition[key]?.isQueryable ?? true
|
||||
|
||||
const orignalDef = value?.definition ?? ModulesDefinition[key]
|
||||
if (
|
||||
!isBoolean(value) &&
|
||||
(isObject(orignalDef) || !isPresent(value.definition))
|
||||
) {
|
||||
value.definition = {
|
||||
...def,
|
||||
...orignalDef,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return configModules
|
||||
}
|
||||
|
||||
protected prepareSharedResourcesAndDeps() {
|
||||
const injectedDependencies = {
|
||||
[ContainerRegistrationKeys.PG_CONNECTION]: this.#container.resolve(
|
||||
ContainerRegistrationKeys.PG_CONNECTION
|
||||
),
|
||||
[ContainerRegistrationKeys.LOGGER]: this.#container.resolve(
|
||||
ContainerRegistrationKeys.LOGGER
|
||||
),
|
||||
}
|
||||
|
||||
const sharedResourcesConfig: ModuleServiceInitializeOptions = {
|
||||
database: {
|
||||
clientUrl:
|
||||
(
|
||||
injectedDependencies[
|
||||
ContainerRegistrationKeys.PG_CONNECTION
|
||||
] as ReturnType<typeof pgConnectionLoader>
|
||||
)?.client?.config?.connection?.connectionString ??
|
||||
configManager.config.projectConfig.databaseUrl,
|
||||
driverOptions: configManager.config.projectConfig.databaseDriverOptions,
|
||||
debug: configManager.config.projectConfig.databaseLogging ?? false,
|
||||
schema: configManager.config.projectConfig.databaseSchema,
|
||||
database: configManager.config.projectConfig.databaseName,
|
||||
},
|
||||
}
|
||||
|
||||
return { sharedResourcesConfig, injectedDependencies }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run, Revert or Generate the migrations for the medusa app.
|
||||
*
|
||||
* @param moduleNames
|
||||
* @param linkModules
|
||||
* @param action
|
||||
*/
|
||||
async runModulesMigrations(
|
||||
{
|
||||
moduleNames,
|
||||
action = "run",
|
||||
}:
|
||||
| {
|
||||
moduleNames?: never
|
||||
action: "run"
|
||||
}
|
||||
| {
|
||||
moduleNames: string[]
|
||||
action: "revert" | "generate"
|
||||
} = {
|
||||
action: "run",
|
||||
}
|
||||
): Promise<void> {
|
||||
const configModules = this.mergeDefaultModules(configManager.config.modules)
|
||||
|
||||
const { sharedResourcesConfig, injectedDependencies } =
|
||||
this.prepareSharedResourcesAndDeps()
|
||||
|
||||
const migrationOptions = {
|
||||
modulesConfig: configModules,
|
||||
sharedContainer: this.#container,
|
||||
linkModules: this.#customLinksModules,
|
||||
sharedResourcesConfig,
|
||||
injectedDependencies,
|
||||
}
|
||||
|
||||
if (action === "revert") {
|
||||
await MedusaAppMigrateDown(moduleNames!, migrationOptions)
|
||||
} else if (action === "run") {
|
||||
await MedusaAppMigrateUp(migrationOptions)
|
||||
} else {
|
||||
await MedusaAppMigrateGenerate(moduleNames!, migrationOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an instance of the link module migration planner.
|
||||
*/
|
||||
async getLinksExecutionPlanner(): Promise<ILinkMigrationsPlanner> {
|
||||
const configModules = this.mergeDefaultModules(configManager.config.modules)
|
||||
const { sharedResourcesConfig, injectedDependencies } =
|
||||
this.prepareSharedResourcesAndDeps()
|
||||
|
||||
const migrationOptions = {
|
||||
modulesConfig: configModules,
|
||||
sharedContainer: this.#container,
|
||||
linkModules: this.#customLinksModules,
|
||||
sharedResourcesConfig,
|
||||
injectedDependencies,
|
||||
}
|
||||
|
||||
return await MedusaAppGetLinksExecutionPlanner(migrationOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the modules loader without taking care of anything else. This is useful for running the loader as a separate action or to re run all modules loaders.
|
||||
*/
|
||||
async runModulesLoader(): Promise<void> {
|
||||
const { sharedResourcesConfig, injectedDependencies } =
|
||||
this.prepareSharedResourcesAndDeps()
|
||||
const configModules = this.mergeDefaultModules(configManager.config.modules)
|
||||
|
||||
await MedusaApp({
|
||||
modulesConfig: configModules,
|
||||
sharedContainer: this.#container,
|
||||
linkModules: this.#customLinksModules,
|
||||
sharedResourcesConfig,
|
||||
injectedDependencies,
|
||||
loaderOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all modules and bootstrap all the modules and links to be ready to be consumed
|
||||
* @param config
|
||||
*/
|
||||
async load(config = { registerInContainer: true }): Promise<MedusaAppOutput> {
|
||||
const configModule: ConfigModule = this.#container.resolve(
|
||||
ContainerRegistrationKeys.CONFIG_MODULE
|
||||
)
|
||||
|
||||
const { sharedResourcesConfig, injectedDependencies } =
|
||||
this.prepareSharedResourcesAndDeps()
|
||||
|
||||
this.#container.register(
|
||||
ContainerRegistrationKeys.REMOTE_QUERY,
|
||||
asValue(undefined)
|
||||
)
|
||||
this.#container.register(
|
||||
ContainerRegistrationKeys.QUERY,
|
||||
asValue(undefined)
|
||||
)
|
||||
this.#container.register(
|
||||
ContainerRegistrationKeys.REMOTE_LINK,
|
||||
asValue(undefined)
|
||||
)
|
||||
|
||||
const configModules = this.mergeDefaultModules(configModule.modules)
|
||||
|
||||
const medusaApp = await MedusaApp({
|
||||
workerMode: configModule.projectConfig.workerMode,
|
||||
modulesConfig: configModules,
|
||||
sharedContainer: this.#container,
|
||||
linkModules: this.#customLinksModules,
|
||||
sharedResourcesConfig,
|
||||
injectedDependencies,
|
||||
})
|
||||
|
||||
if (!config.registerInContainer) {
|
||||
return medusaApp
|
||||
}
|
||||
|
||||
this.#container.register(
|
||||
ContainerRegistrationKeys.REMOTE_LINK,
|
||||
asValue(medusaApp.link)
|
||||
)
|
||||
this.#container.register(
|
||||
ContainerRegistrationKeys.REMOTE_QUERY,
|
||||
asValue(medusaApp.query)
|
||||
)
|
||||
this.#container.register(
|
||||
ContainerRegistrationKeys.QUERY,
|
||||
asValue(medusaApp.query)
|
||||
)
|
||||
|
||||
for (const moduleService of Object.values(medusaApp.modules)) {
|
||||
const loadedModule = moduleService as LoadedModule
|
||||
container.register(loadedModule.__definition.key, asValue(moduleService))
|
||||
}
|
||||
|
||||
// Register all unresolved modules as undefined to be present in the container with undefined value by default
|
||||
// but still resolvable
|
||||
for (const moduleDefinition of Object.values(ModulesDefinition)) {
|
||||
if (!container.hasRegistration(moduleDefinition.key)) {
|
||||
container.register(moduleDefinition.key, asValue(undefined))
|
||||
}
|
||||
}
|
||||
|
||||
return medusaApp
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
112
packages/core/framework/src/subscribers/__tests__/index.spec.ts
Normal file
112
packages/core/framework/src/subscribers/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Modules } from "@medusajs/utils"
|
||||
import { asValue } from "awilix"
|
||||
import { join } from "path"
|
||||
import { container } from "../../container"
|
||||
import { eventBusServiceMock } from "../__mocks__"
|
||||
import { SubscriberLoader } from "../subscriber-loader"
|
||||
|
||||
describe("SubscriberLoader", () => {
|
||||
const rootDir = join(__dirname, "../__fixtures__", "subscribers")
|
||||
|
||||
const pluginOptions = {
|
||||
important_data: {
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
let registeredPaths: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
container.register(Modules.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/core/framework/src/subscribers/index.ts
Normal file
2
packages/core/framework/src/subscribers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./subscriber-loader"
|
||||
export * from "./types"
|
||||
254
packages/core/framework/src/subscribers/subscriber-loader.ts
Normal file
254
packages/core/framework/src/subscribers/subscriber-loader.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Event, IEventBusModuleService, Subscriber } from "@medusajs/types"
|
||||
import { Modules, kebabCase, promiseAll } from "@medusajs/utils"
|
||||
import { access, readdir } from "fs/promises"
|
||||
import { join, parse } from "path"
|
||||
|
||||
import { configManager } from "../config"
|
||||
import { container } from "../container"
|
||||
import { logger } from "../logger"
|
||||
import { SubscriberArgs, SubscriberConfig } from "./types"
|
||||
|
||||
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 | string[]
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the subscriber scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/index\.js/,
|
||||
/index\.ts/,
|
||||
/\.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 | 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) => {
|
||||
const fileEntries = entries.filter((entry) => {
|
||||
return (
|
||||
!entry.isDirectory() &&
|
||||
!this.#excludes.some((exclude) => exclude.test(entry.name))
|
||||
)
|
||||
})
|
||||
|
||||
logger.debug(`Registering subscribers from ${dirPath}.`)
|
||||
|
||||
return fileEntries.flatMap(async (entry) => {
|
||||
const fullPath = join(entry.path, entry.name)
|
||||
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(
|
||||
Modules.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() {
|
||||
const normalizeSourcePaths = Array.isArray(this.#sourceDir)
|
||||
? this.#sourceDir
|
||||
: [this.#sourceDir]
|
||||
const promises = normalizeSourcePaths.map(async (sourcePath) => {
|
||||
try {
|
||||
await access(sourcePath)
|
||||
} catch {
|
||||
logger.info(`No subscribers to load from ${sourcePath}. skipped.`)
|
||||
return
|
||||
}
|
||||
|
||||
return await this.createMap(sourcePath)
|
||||
})
|
||||
|
||||
await promiseAll(promises)
|
||||
|
||||
for (const [
|
||||
fileName,
|
||||
{ config, handler },
|
||||
] of this.#subscriberDescriptors.entries()) {
|
||||
this.createSubscriber({
|
||||
fileName,
|
||||
config,
|
||||
handler,
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug(`Subscribers registered.`)
|
||||
|
||||
/**
|
||||
* 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/core/framework/src/subscribers/types.ts
Normal file
16
packages/core/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>
|
||||
}
|
||||
79
packages/core/framework/src/telemetry/index.ts
Normal file
79
packages/core/framework/src/telemetry/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Tracer as OTTracer,
|
||||
Span,
|
||||
trace,
|
||||
context,
|
||||
propagation,
|
||||
} from "@opentelemetry/api"
|
||||
|
||||
/**
|
||||
* Tracer creates an instrumentation scope within the application
|
||||
* code. For example: You might create a tracer for the API
|
||||
* requests, another one for the modules, one for workflows
|
||||
* and so on.
|
||||
*
|
||||
* There is no need to create a Tracer instance per HTTP
|
||||
* call.
|
||||
*/
|
||||
export class Tracer {
|
||||
/**
|
||||
* Reference to the underlying OpenTelemetry tracer
|
||||
*/
|
||||
#otTracer: OTTracer
|
||||
constructor(public name: string, public version?: string) {
|
||||
this.#otTracer = trace.getTracer(name, version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the underlying tracer from open telemetry that
|
||||
* could be used directly for certain advanced use-cases
|
||||
*/
|
||||
getOTTracer() {
|
||||
return this.#otTracer
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a function call. Using this method will create a
|
||||
* child scope for the invocations within the callback.
|
||||
*/
|
||||
trace<F extends (span: Span) => unknown>(
|
||||
name: string,
|
||||
callback: F
|
||||
): ReturnType<F> {
|
||||
return this.#otTracer.startActiveSpan(name, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active context
|
||||
*/
|
||||
getActiveContext() {
|
||||
return context.active()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the propagation state from the current active
|
||||
* context
|
||||
*/
|
||||
getPropagationState() {
|
||||
let output = {}
|
||||
propagation.inject(context.active(), output)
|
||||
return output as { traceparent: string; tracestate?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the existing propogation state and trace an action. This
|
||||
* will allow the newly traced action to be part of some
|
||||
* existing trace
|
||||
*/
|
||||
withPropagationState(state: { traceparent: string; tracestate?: string }) {
|
||||
return {
|
||||
trace: <F extends (span: Span) => unknown>(
|
||||
name: string,
|
||||
callback: F
|
||||
): ReturnType<F> => {
|
||||
const activeContext = propagation.extract(context.active(), state)
|
||||
return this.#otTracer.startActiveSpan(name, {}, activeContext, callback)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
|
||||
export const productWorkflowId = "product-notifier-workflow"
|
||||
|
||||
const step = createStep("product-step", () => {
|
||||
return {} as any
|
||||
})
|
||||
|
||||
export const productUpdatedWorkflow = createWorkflow(productWorkflowId, () => {
|
||||
step()
|
||||
return new WorkflowResponse(void 0)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
|
||||
export const orderWorkflowId = "order-notifier-workflow"
|
||||
|
||||
const step = createStep("order-step", () => {
|
||||
return {} as any
|
||||
})
|
||||
|
||||
export const orderNotifierWorkflow = createWorkflow(orderWorkflowId, () => {
|
||||
step()
|
||||
return new WorkflowResponse(void 0)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { join } from "path"
|
||||
import { WorkflowLoader } from "../workflow-loader"
|
||||
import { WorkflowManager } from "@medusajs/orchestration"
|
||||
import { orderWorkflowId } from "../__fixtures__/workflows/order-notifier"
|
||||
import { productWorkflowId } from "../__fixtures__/workflows/deep-workflows/product-updater"
|
||||
|
||||
describe("WorkflowLoader", () => {
|
||||
const rootDir = join(__dirname, "../__fixtures__", "workflows")
|
||||
|
||||
beforeAll(async () => {
|
||||
await new WorkflowLoader(rootDir).load()
|
||||
})
|
||||
|
||||
it("should register each workflow in the '/workflows' folder and sub folder", async () => {
|
||||
const registeredWorkflows = WorkflowManager.getWorkflows()
|
||||
|
||||
expect(registeredWorkflows.size).toBe(2)
|
||||
expect(registeredWorkflows.has(orderWorkflowId)).toBe(true)
|
||||
expect(registeredWorkflows.has(productWorkflowId)).toBe(true)
|
||||
})
|
||||
})
|
||||
1
packages/core/framework/src/workflows/index.ts
Normal file
1
packages/core/framework/src/workflows/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./workflow-loader"
|
||||
72
packages/core/framework/src/workflows/workflow-loader.ts
Normal file
72
packages/core/framework/src/workflows/workflow-loader.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { promiseAll } from "@medusajs/utils"
|
||||
import { logger } from "../logger"
|
||||
import { access, readdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
|
||||
export class WorkflowLoader {
|
||||
/**
|
||||
* The directory from which to load the workflows
|
||||
* @private
|
||||
*/
|
||||
#sourceDir: string | string[]
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the subscriber scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/index\.js/,
|
||||
/index\.ts/,
|
||||
/\.DS_Store/,
|
||||
/(\.ts\.map|\.js\.map|\.d\.ts|\.md)/,
|
||||
/^_[^/\\]*(\.[^/\\]+)?$/,
|
||||
]
|
||||
|
||||
constructor(sourceDir: string | string[]) {
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workflows from the source paths, workflows are registering themselves,
|
||||
* therefore we only need to import them
|
||||
*/
|
||||
async load() {
|
||||
const normalizedSourcePath = Array.isArray(this.#sourceDir)
|
||||
? this.#sourceDir
|
||||
: [this.#sourceDir]
|
||||
|
||||
const promises = normalizedSourcePath.map(async (sourcePath) => {
|
||||
try {
|
||||
await access(sourcePath)
|
||||
} catch {
|
||||
logger.info(`No workflow to load from ${sourcePath}. skipped.`)
|
||||
return
|
||||
}
|
||||
|
||||
return await readdir(sourcePath, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
}).then(async (entries) => {
|
||||
const fileEntries = entries.filter((entry) => {
|
||||
return (
|
||||
!entry.isDirectory() &&
|
||||
!this.#excludes.some((exclude) => exclude.test(entry.name))
|
||||
)
|
||||
})
|
||||
|
||||
logger.debug(`Registering workflows from ${sourcePath}.`)
|
||||
|
||||
return await promiseAll(
|
||||
fileEntries.map(async (entry) => {
|
||||
const fullPath = join(entry.path, entry.name)
|
||||
return await import(fullPath)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
await promiseAll(promises)
|
||||
|
||||
logger.debug(`Workflows registered.`)
|
||||
}
|
||||
}
|
||||
32
packages/core/framework/tsconfig.json
Normal file
32
packages/core/framework/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"declarationMap": true,
|
||||
"declaration": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": false,
|
||||
"noImplicitReturns": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitThis": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
},
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user