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 11:28:33 +00:00
committed by GitHub
parent c1057410d9
commit ec56a8bc85
135 changed files with 2766 additions and 2422 deletions
@@ -0,0 +1,15 @@
import type { InlineConfig } from "vite"
import { BundlerOptions } from "../types"
import { getViteConfig } from "../utils/config"
export async function build(options: BundlerOptions) {
const vite = await import("vite")
const viteConfig = await getViteConfig(options)
const buildConfig: InlineConfig = {
mode: "production",
logLevel: "error",
}
await vite.build(vite.mergeConfig(viteConfig, buildConfig))
}
@@ -0,0 +1,98 @@
import express, { RequestHandler } from "express"
import fs from "fs"
import path from "path"
import type { InlineConfig, ViteDevServer } from "vite"
import { BundlerOptions } from "../types"
import { getViteConfig } from "../utils/config"
const router = express.Router()
function findTemplateFilePath(
reqPath: string,
root: string
): string | undefined {
if (reqPath.endsWith(".html")) {
const pathToTest = path.join(root, reqPath)
if (fs.existsSync(pathToTest)) {
return pathToTest
}
}
const basePath = reqPath.slice(0, reqPath.lastIndexOf("/"))
const dirs = basePath.split("/")
while (dirs.length > 0) {
const pathToTest = path.join(root, ...dirs, "index.html")
if (fs.existsSync(pathToTest)) {
return pathToTest
}
dirs.pop()
}
return undefined
}
async function injectViteMiddleware(
router: express.Router,
middleware: RequestHandler
) {
router.use((req, res, next) => {
req.path.endsWith(".html") ? next() : middleware(req, res, next)
})
}
async function injectHtmlMiddleware(
router: express.Router,
server: ViteDevServer
) {
router.use(async (req, res, next) => {
if (req.method !== "GET") {
return next()
}
const templateFilePath = findTemplateFilePath(req.path, server.config.root)
if (!templateFilePath) {
return next()
}
const template = fs.readFileSync(templateFilePath, "utf8")
const html = await server.transformIndexHtml(
templateFilePath,
template,
req.originalUrl
)
res.send(html)
})
}
export async function develop(options: BundlerOptions) {
const vite = await import("vite")
try {
const viteConfig = await getViteConfig(options)
const developConfig: InlineConfig = {
mode: "development",
logLevel: "error",
appType: "spa",
server: {
middlewareMode: true,
},
}
const mergedConfig = vite.mergeConfig(viteConfig, developConfig)
const server = await vite.createServer(mergedConfig)
await injectViteMiddleware(router, server.middlewares)
await injectHtmlMiddleware(router, server)
} catch (error) {
console.error(error)
throw new Error(
"Failed to start admin development server. See error above."
)
}
return router
}
@@ -0,0 +1,112 @@
import { readFileSync } from "fs"
import { builtinModules } from "node:module"
import path from "path"
import type { UserConfig } from "vite"
import { clearPluginBuild } from "../plugins/clear-plugin-build"
interface PluginOptions {
root: string
outDir: string
}
export async function plugin(options: PluginOptions) {
const vite = await import("vite")
const react = (await import("@vitejs/plugin-react")).default
const medusa = (await import("@medusajs/admin-vite-plugin")).default
const pkg = JSON.parse(
readFileSync(path.resolve(options.root, "package.json"), "utf-8")
)
const external = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.devDependencies || {}),
"react",
"react/jsx-runtime",
"react-router-dom",
"@medusajs/js-sdk",
"@medusajs/admin-sdk",
"@tanstack/react-query",
])
const outDir = path.resolve(options.root, options.outDir, "src/admin")
const entryPoint = path.resolve(
options.root,
"src/admin/__admin-extensions__.js"
)
/**
* We need to ensure that the NODE_ENV is set to production,
* otherwise Vite will build the dev version of React.
*/
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = "production"
const pluginConfig: UserConfig = {
build: {
lib: {
entry: entryPoint,
formats: ["es", "cjs"],
fileName: "index",
},
emptyOutDir: false,
minify: false,
outDir,
rollupOptions: {
external: (id, importer) => {
// If there's no importer, it's a direct dependency
// Keep the existing external behavior
if (!importer) {
const idParts = id.split("/")
const name = idParts[0]?.startsWith("@")
? `${idParts[0]}/${idParts[1]}`
: idParts[0]
const builtinModulesWithNodePrefix = [
...builtinModules,
...builtinModules.map((modName) => `node:${modName}`),
]
return Boolean(
(name && external.has(name)) ||
(name && builtinModulesWithNodePrefix.includes(name))
)
}
// For transient dependencies (those with importers),
// bundle them if they're not in our external set
const idParts = id.split("/")
const name = idParts[0]?.startsWith("@")
? `${idParts[0]}/${idParts[1]}`
: idParts[0]
return Boolean(name && external.has(name))
},
output: {
preserveModules: false,
interop: "auto",
chunkFileNames: () => {
return `_chunks/[name]-[hash]`
},
},
},
},
plugins: [
react(),
medusa({
pluginMode: true,
sources: [path.resolve(options.root, "src/admin")],
}),
clearPluginBuild({ outDir }),
],
logLevel: "silent",
clearScreen: false,
}
await vite.build(pluginConfig)
/**
* Restore the original NODE_ENV
*/
process.env.NODE_ENV = originalNodeEnv
}
@@ -0,0 +1,56 @@
import compression from "compression"
import { Request, Response, Router, static as static_ } from "express"
import fs from "fs"
import { ServerResponse } from "http"
import path from "path"
type ServeOptions = {
outDir: string
}
const router = Router()
export async function serve(options: ServeOptions) {
const htmlPath = path.resolve(options.outDir, "index.html")
/**
* The admin UI should always be built at this point, but in the
* rare case that another plugin terminated a previous startup, the admin
* may not have been built correctly. Here we check if the admin UI
* build files exist, and if not, we throw an error, providing the
* user with instructions on how to fix their build.
*/
const indexExists = fs.existsSync(htmlPath)
if (!indexExists) {
throw new Error(
`Could not find index.html in the admin build directory. Make sure to run 'medusa build' before starting the server.`
)
}
const html = fs.readFileSync(htmlPath, "utf-8")
const sendHtml = (_req: Request, res: Response) => {
res.setHeader("Cache-Control", "no-cache")
res.setHeader("Vary", "Origin, Cache-Control")
res.send(html)
}
const setStaticHeaders = (res: ServerResponse) => {
res.setHeader("Cache-Control", "max-age=31536000, immutable")
res.setHeader("Vary", "Origin, Cache-Control")
}
router.use(compression())
router.get("/", sendHtml)
router.use(
static_(options.outDir, {
setHeaders: setStaticHeaders,
})
)
router.get(`/*`, sendHtml)
return router
}