fix(medusa,utils,test-utils,types,framework,dashboard,admin-vite-plugin,admin-bundler): Fix broken plugin dependencies in development server (#11720)

**What**
- Reworks how admin extensions are loaded from plugins.
- Reworks how extensions are managed internally in the dashboard project.

**Why**
- Previously we loaded extensions from plugins the same way we do for extension found in a users application. This being scanning the source code for possible extensions in `.medusa/server/src/admin`, and including any extensions that were discovered in the final virtual modules.
- This was causing issues with how Vite optimizes dependencies, and would lead to CJS/ESM issues. Not sure of the exact cause of this, but the issue was pinpointed to Vite not being able to register correctly which dependencies to optimize when they were loaded through the virtual module from a plugin in `node_modules`.

**What changed**
- To circumvent the above issue we have changed to a different strategy for loading extensions from plugins. The changes are the following:
  - We now build plugins slightly different, if a plugin has admin extensions we now build those to `.medusa/server/src/admin/index.mjs` and `.medusa/server/src/admin/index.js` for a ESM and CJS build.
  - When determining how to load extensions from a source we follow these rules:
    - If the source has a `medusa-plugin-options.json` or is the root application we determine that it is a `local` extension source, and load extensions as previously through a virtual module.
    - If it has neither of the above, but has a `./admin` export in its package.json then we determine that it is a `package` extension, and we update the entry point for the dashboard to import the package and pass its extensions a long to the dashboard manager.

**Changes required by plugin authors**
- The change has no breaking changes, but requires plugin authors to update the `package.json` of their plugins to also include a `./admin` export. It should look like this:

```json
{
  "name": "@medusajs/plugin",
  "version": "0.0.1",
  "description": "A starter for Medusa plugins.",
  "author": "Medusa (https://medusajs.com)",
  "license": "MIT",
  "files": [
    ".medusa/server"
  ],
  "exports": {
    "./package.json": "./package.json",
    "./workflows": "./.medusa/server/src/workflows/index.js",
    "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
    "./modules/*": "./.medusa/server/src/modules/*/index.js",
    "./providers/*": "./.medusa/server/src/providers/*/index.js",
    "./*": "./.medusa/server/src/*.js",
    "./admin": {
      "import": "./.medusa/server/src/admin/index.mjs",
      "require": "./.medusa/server/src/admin/index.js",
      "default": "./.medusa/server/src/admin/index.js"
    }
  },
}
```
This commit is contained in:
Kasper Fabricius Kristensen
2025-03-11 12:28:33 +01:00
committed by GitHub
parent c1057410d9
commit ec56a8bc85
135 changed files with 2766 additions and 2422 deletions
@@ -0,0 +1,279 @@
import path from "path"
import { defineConfig } from "../define-config"
import { FileSystem } from "../file-system"
import { getResolvedPlugins } from "../get-resolved-plugins"
const BASE_DIR = path.join(__dirname, "sample-proj")
const fs = new FileSystem(BASE_DIR)
afterEach(async () => {
await fs.cleanup()
})
describe("getResolvedPlugins | relative paths", () => {
test("resolve configured plugins", async () => {
await fs.createJson("plugins/dummy/package.json", {
name: "my-dummy-plugin",
version: "1.0.0",
})
const plugins = await getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "./plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
expect(plugins).toEqual([
{
resolve: path.join(fs.basePath, "./plugins/dummy/.medusa/server/src"),
admin: undefined,
name: "my-dummy-plugin",
id: "my-dummy-plugin",
options: { apiKey: "asecret" },
version: "1.0.0",
modules: [],
},
])
})
test("scan plugin modules", async () => {
await fs.createJson("plugins/dummy/package.json", {
name: "my-dummy-plugin",
version: "1.0.0",
})
await fs.create(
"plugins/dummy/.medusa/server/src/modules/blog/index.js",
``
)
const plugins = await getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "./plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
expect(plugins).toEqual([
{
resolve: path.join(fs.basePath, "./plugins/dummy/.medusa/server/src"),
admin: undefined,
name: "my-dummy-plugin",
id: "my-dummy-plugin",
options: { apiKey: "asecret" },
version: "1.0.0",
modules: [
{
options: {
apiKey: "asecret",
},
resolve: "./plugins/dummy/.medusa/server/src/modules/blog",
},
],
},
])
})
test("throw error when package.json file is missing", async () => {
const resolvePlugins = async () =>
getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "./plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
await expect(resolvePlugins()).rejects.toThrow(
`Unable to resolve plugin "./plugins/dummy". Make sure the plugin directory has a package.json file`
)
})
test("resolve admin source from medusa-plugin-options file", async () => {
await fs.createJson("plugins/dummy/package.json", {
name: "my-dummy-plugin",
version: "1.0.0",
})
await fs.create(
"plugins/dummy/.medusa/server/src/modules/blog/index.js",
``
)
await fs.createJson(
"plugins/dummy/.medusa/server/medusa-plugin-options.json",
{
srcDir: path.join(fs.basePath, "plugins/dummy/src"),
}
)
const plugins = await getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "./plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
expect(plugins).toEqual([
{
resolve: path.join(fs.basePath, "./plugins/dummy/.medusa/server/src"),
admin: {
type: "local",
resolve: path.join(fs.basePath, "./plugins/dummy/src/admin"),
},
name: "my-dummy-plugin",
id: "my-dummy-plugin",
options: { apiKey: "asecret" },
version: "1.0.0",
modules: [
{
options: {
apiKey: "asecret",
},
resolve: "./plugins/dummy/.medusa/server/src/modules/blog",
},
],
},
])
})
})
describe("getResolvedPlugins | package reference", () => {
test("resolve configured plugins", async () => {
await fs.createJson("package.json", {})
await fs.createJson("node_modules/@plugins/dummy/package.json", {
name: "my-dummy-plugin",
version: "1.0.0",
})
const plugins = await getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "@plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
expect(plugins).toEqual([
{
resolve: path.join(
fs.basePath,
"node_modules/@plugins/dummy/.medusa/server/src"
),
admin: undefined,
name: "my-dummy-plugin",
id: "my-dummy-plugin",
options: { apiKey: "asecret" },
version: "1.0.0",
modules: [],
},
])
})
test("scan plugin modules", async () => {
await fs.createJson("package.json", {})
await fs.createJson("node_modules/@plugins/dummy/package.json", {
name: "my-dummy-plugin",
version: "1.0.0",
})
await fs.create(
"node_modules/@plugins/dummy/.medusa/server/src/modules/blog/index.js",
``
)
const plugins = await getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "@plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
expect(plugins).toEqual([
{
resolve: path.join(
fs.basePath,
"node_modules/@plugins/dummy/.medusa/server/src"
),
admin: undefined,
name: "my-dummy-plugin",
id: "my-dummy-plugin",
options: { apiKey: "asecret" },
version: "1.0.0",
modules: [
{
options: {
apiKey: "asecret",
},
resolve: "@plugins/dummy/.medusa/server/src/modules/blog",
},
],
},
])
})
test("throw error when package.json file is missing", async () => {
const resolvePlugins = async () =>
getResolvedPlugins(
fs.basePath,
defineConfig({
plugins: [
{
resolve: "@plugins/dummy",
options: {
apiKey: "asecret",
},
},
],
}),
false
)
await expect(resolvePlugins()).rejects.toThrow(
`Unable to resolve plugin "@plugins/dummy". Make sure the plugin directory has a package.json file`
)
})
})
@@ -0,0 +1,153 @@
import { ConfigModule, PluginDetails } from "@medusajs/types"
import fs from "fs/promises"
import path from "path"
import { isString } from "./is-string"
import { readDir } from "./read-dir-recursive"
const MEDUSA_APP_SOURCE_PATH = "src"
const MEDUSA_PLUGIN_SOURCE_PATH = ".medusa/server/src"
const MEDUSA_PLUGIN_OPTIONS_FILE_PATH =
".medusa/server/medusa-plugin-options.json"
export const MEDUSA_PROJECT_NAME = "project-plugin"
function createPluginId(name: string): string {
return name
}
function createFileContentHash(path: string, files: string): string {
return path + files
}
/**
* Returns the absolute path to the package.json file for a
* given plugin identifier.
*/
async function resolvePluginPkgFile(
rootDirectory: string,
pluginPath: string
): Promise<{ path: string; contents: any }> {
try {
const pkgJSONPath = require.resolve(path.join(pluginPath, "package.json"), {
paths: [rootDirectory],
})
const packageJSONContents = JSON.parse(
await fs.readFile(pkgJSONPath, "utf-8")
)
return { path: pkgJSONPath, contents: packageJSONContents }
} catch (error) {
if (error.code === "MODULE_NOT_FOUND" || error.code === "ENOENT") {
throw new Error(
`Unable to resolve plugin "${pluginPath}". Make sure the plugin directory has a package.json file`
)
}
throw error
}
}
/**
* Reads the "medusa-plugin-options.json" file from the plugin root
* directory and returns its contents as an object.
*/
async function resolvePluginOptions(
pluginRootDir: string
): Promise<Record<string, any>> {
try {
const contents = await fs.readFile(
path.join(pluginRootDir, MEDUSA_PLUGIN_OPTIONS_FILE_PATH),
"utf-8"
)
return JSON.parse(contents)
} catch (error) {
if (error.code === "MODULE_NOT_FOUND" || error.code === "ENOENT") {
return {}
}
throw error
}
}
/**
* Finds the correct path for the plugin. If it is a local plugin it will be
* found in the plugins folder. Otherwise we will look for the plugin in the
* installed npm packages.
* @param {string} pluginPath - the name of the plugin to find. Should match
* the name of the folder where the plugin is contained.
* @return {object} the plugin details
*/
async function resolvePlugin(
rootDirectory: string,
pluginPath: string,
options?: any
): Promise<PluginDetails> {
const pkgJSON = await resolvePluginPkgFile(rootDirectory, pluginPath)
const resolvedPath = path.dirname(pkgJSON.path)
const name = pkgJSON.contents.name || pluginPath
const resolve = path.join(resolvedPath, MEDUSA_PLUGIN_SOURCE_PATH)
const pluginStaticOptions = await resolvePluginOptions(resolvedPath)
const modules = await readDir(path.join(resolve, "modules"), {
ignoreMissing: true,
})
const pluginOptions = options ?? {}
const hasAdmin =
!!pkgJSON.contents.exports?.["./admin"] || !!pluginStaticOptions.srcDir
const isAdminLocal = hasAdmin && !!pluginStaticOptions.srcDir
const adminConfig = hasAdmin
? {
type: isAdminLocal ? ("local" as const) : ("package" as const),
resolve: path.join(
isAdminLocal ? pluginStaticOptions.srcDir : name,
"admin"
),
}
: undefined
return {
resolve,
name,
id: createPluginId(name),
options: pluginOptions,
version: pkgJSON.contents.version || "0.0.0",
admin: adminConfig,
modules: modules.map((mod) => {
return {
resolve: `${pluginPath}/${MEDUSA_PLUGIN_SOURCE_PATH}/modules/${mod.name}`,
options: pluginOptions,
}
}),
}
}
export async function getResolvedPlugins(
rootDirectory: string,
configModule: ConfigModule,
isMedusaProject = false
): Promise<PluginDetails[]> {
const resolved = await Promise.all(
(configModule?.plugins || []).map(async (plugin) => {
if (isString(plugin)) {
return resolvePlugin(rootDirectory, plugin)
}
return resolvePlugin(rootDirectory, plugin.resolve, plugin.options)
})
)
if (isMedusaProject) {
const extensionDirectory = path.join(rootDirectory, MEDUSA_APP_SOURCE_PATH)
resolved.push({
resolve: extensionDirectory,
name: MEDUSA_PROJECT_NAME,
id: createPluginId(MEDUSA_PROJECT_NAME),
admin: {
type: "local",
resolve: path.join(extensionDirectory, "admin"),
},
options: configModule,
version: createFileContentHash(process.cwd(), `**`),
})
}
return resolved
}
+1
View File
@@ -27,6 +27,7 @@ export * from "./get-config-file"
export * from "./get-duplicates"
export * from "./get-iso-string-from-date"
export * from "./get-node-version"
export * from "./get-resolved-plugins"
export * from "./get-selects-and-relations-from-object-array"
export * from "./get-set-difference"
export * from "./graceful-shutdown-server"