**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"
}
},
}
```
296 lines
7.9 KiB
TypeScript
296 lines
7.9 KiB
TypeScript
import { SourceMap } from "magic-string"
|
|
import { rm, writeFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import type * as Vite from "vite"
|
|
import { generateCustomFieldHashes } from "./custom-fields"
|
|
import { generateRouteHashes } from "./routes"
|
|
import { MedusaVitePlugin } from "./types"
|
|
import { AdminSubdirectory, isFileInAdminSubdirectory } from "./utils"
|
|
import {
|
|
generateVirtualDisplayModule,
|
|
generateVirtualFormModule,
|
|
generateVirtualLinkModule,
|
|
generateVirtualMenuItemModule,
|
|
generateVirtualRouteModule,
|
|
generateVirtualWidgetModule,
|
|
} from "./virtual-modules"
|
|
import {
|
|
isResolvedVirtualModuleId,
|
|
isVirtualModuleId,
|
|
resolveVirtualId,
|
|
VirtualModule,
|
|
vmod,
|
|
} from "./vmod"
|
|
import { generateWidgetHash } from "./widgets"
|
|
|
|
enum Mode {
|
|
PLUGIN = "plugin",
|
|
APPLICATION = "application",
|
|
}
|
|
|
|
export const medusaVitePlugin: MedusaVitePlugin = (options) => {
|
|
const hashMap = new Map<VirtualModule, string>()
|
|
const _sources = new Set<string>(options?.sources ?? [])
|
|
|
|
const mode = options?.pluginMode ? Mode.PLUGIN : Mode.APPLICATION
|
|
|
|
let watcher: Vite.FSWatcher | undefined
|
|
|
|
function isFileInSources(file: string): boolean {
|
|
for (const source of _sources) {
|
|
if (file.startsWith(path.resolve(source))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function loadVirtualModule(
|
|
config: ModuleConfig
|
|
): Promise<{ code: string; map: SourceMap } | null> {
|
|
const hash = await config.hashGenerator(_sources)
|
|
hashMap.set(config.hashKey, hash)
|
|
|
|
return config.moduleGenerator(_sources)
|
|
}
|
|
|
|
async function handleFileChange(
|
|
server: Vite.ViteDevServer,
|
|
config: WatcherConfig
|
|
) {
|
|
const hashes = await config.hashGenerator(_sources)
|
|
|
|
for (const module of config.modules) {
|
|
const newHash = hashes[module.hashKey]
|
|
if (newHash !== hashMap.get(module.virtualModule)) {
|
|
const moduleToReload = server.moduleGraph.getModuleById(
|
|
module.resolvedModule
|
|
)
|
|
if (moduleToReload) {
|
|
await server.reloadModule(moduleToReload)
|
|
}
|
|
hashMap.set(module.virtualModule, newHash)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function to generate the index.js file
|
|
async function generatePluginEntryModule(
|
|
sources: Set<string>
|
|
): Promise<string> {
|
|
// Generate all the module content
|
|
const widgetModule = await generateVirtualWidgetModule(sources, true)
|
|
const routeModule = await generateVirtualRouteModule(sources, true)
|
|
const menuItemModule = await generateVirtualMenuItemModule(sources, true)
|
|
const formModule = await generateVirtualFormModule(sources, true)
|
|
const displayModule = await generateVirtualDisplayModule(sources, true)
|
|
|
|
// Create the index.js content that re-exports everything
|
|
return `
|
|
// Auto-generated index file for Medusa Admin UI extensions
|
|
${widgetModule.code}
|
|
${routeModule.code}
|
|
${menuItemModule.code}
|
|
${formModule.code}
|
|
${displayModule.code}
|
|
|
|
const plugin = {
|
|
widgetModule,
|
|
routeModule,
|
|
menuItemModule,
|
|
formModule,
|
|
displayModule
|
|
}
|
|
|
|
export default plugin
|
|
`
|
|
}
|
|
|
|
const pluginEntryFile = path.resolve(
|
|
process.cwd(),
|
|
"src/admin/__admin-extensions__.js"
|
|
)
|
|
|
|
return {
|
|
name: "@medusajs/admin-vite-plugin",
|
|
enforce: "pre",
|
|
async buildStart() {
|
|
switch (mode) {
|
|
case Mode.PLUGIN: {
|
|
const code = await generatePluginEntryModule(_sources)
|
|
await writeFile(pluginEntryFile, code, "utf-8")
|
|
break
|
|
}
|
|
case Mode.APPLICATION: {
|
|
break
|
|
}
|
|
}
|
|
},
|
|
async buildEnd() {
|
|
switch (mode) {
|
|
case Mode.PLUGIN: {
|
|
try {
|
|
await rm(pluginEntryFile, { force: true })
|
|
} catch (error) {
|
|
// Ignore the error if the file doesn't exist
|
|
}
|
|
break
|
|
}
|
|
case Mode.APPLICATION: {
|
|
break
|
|
}
|
|
}
|
|
},
|
|
configureServer(server) {
|
|
watcher = server.watcher
|
|
watcher?.add(Array.from(_sources))
|
|
|
|
watcher?.on("all", async (_event, file) => {
|
|
if (!isFileInSources(file)) {
|
|
return
|
|
}
|
|
|
|
for (const config of watcherConfigs) {
|
|
if (isFileInAdminSubdirectory(file, config.subdirectory)) {
|
|
await handleFileChange(server, config)
|
|
}
|
|
}
|
|
})
|
|
},
|
|
resolveId(id) {
|
|
if (!isVirtualModuleId(id)) {
|
|
return null
|
|
}
|
|
|
|
return resolveVirtualId(id)
|
|
},
|
|
async load(id) {
|
|
if (!isResolvedVirtualModuleId(id)) {
|
|
return null
|
|
}
|
|
|
|
const config = loadConfigs[id]
|
|
|
|
if (!config) {
|
|
return null
|
|
}
|
|
|
|
return loadVirtualModule(config)
|
|
},
|
|
async closeBundle() {
|
|
if (watcher) {
|
|
await watcher.close()
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
type ModuleConfig = {
|
|
hashGenerator: (sources: Set<string>) => Promise<string>
|
|
moduleGenerator: (
|
|
sources: Set<string>
|
|
) => Promise<{ code: string; map: SourceMap }>
|
|
hashKey: VirtualModule
|
|
}
|
|
|
|
const loadConfigs: Record<string, ModuleConfig> = {
|
|
[vmod.resolved.widget]: {
|
|
hashGenerator: async (sources) => generateWidgetHash(sources),
|
|
moduleGenerator: async (sources) => generateVirtualWidgetModule(sources),
|
|
hashKey: vmod.virtual.widget,
|
|
},
|
|
[vmod.resolved.link]: {
|
|
hashGenerator: async (sources) =>
|
|
(await generateCustomFieldHashes(sources)).linkHash,
|
|
moduleGenerator: async (sources) => generateVirtualLinkModule(sources),
|
|
hashKey: vmod.virtual.link,
|
|
},
|
|
[vmod.resolved.form]: {
|
|
hashGenerator: async (sources) =>
|
|
(await generateCustomFieldHashes(sources)).formHash,
|
|
moduleGenerator: async (sources) => generateVirtualFormModule(sources),
|
|
hashKey: vmod.virtual.form,
|
|
},
|
|
[vmod.resolved.display]: {
|
|
hashGenerator: async (sources) =>
|
|
(await generateCustomFieldHashes(sources)).displayHash,
|
|
moduleGenerator: async (sources) => generateVirtualDisplayModule(sources),
|
|
hashKey: vmod.virtual.display,
|
|
},
|
|
[vmod.resolved.route]: {
|
|
hashGenerator: async (sources) =>
|
|
(await generateRouteHashes(sources)).defaultExportHash,
|
|
moduleGenerator: async (sources) => generateVirtualRouteModule(sources),
|
|
hashKey: vmod.virtual.route,
|
|
},
|
|
[vmod.resolved.menuItem]: {
|
|
hashGenerator: async (sources) =>
|
|
(await generateRouteHashes(sources)).configHash,
|
|
moduleGenerator: async (sources) => generateVirtualMenuItemModule(sources),
|
|
hashKey: vmod.virtual.menuItem,
|
|
},
|
|
}
|
|
|
|
type WatcherConfig = {
|
|
subdirectory: AdminSubdirectory
|
|
hashGenerator: (sources: Set<string>) => Promise<Record<string, string>>
|
|
modules: {
|
|
virtualModule: VirtualModule
|
|
resolvedModule: string
|
|
hashKey: string
|
|
}[]
|
|
}
|
|
|
|
const watcherConfigs: WatcherConfig[] = [
|
|
{
|
|
subdirectory: "routes",
|
|
hashGenerator: async (sources) => generateRouteHashes(sources),
|
|
modules: [
|
|
{
|
|
virtualModule: vmod.virtual.route,
|
|
resolvedModule: vmod.resolved.route,
|
|
hashKey: "defaultExportHash",
|
|
},
|
|
{
|
|
virtualModule: vmod.virtual.menuItem,
|
|
resolvedModule: vmod.resolved.menuItem,
|
|
hashKey: "configHash",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
subdirectory: "widgets",
|
|
hashGenerator: async (sources) => ({
|
|
widgetConfigHash: await generateWidgetHash(sources),
|
|
}),
|
|
modules: [
|
|
{
|
|
virtualModule: vmod.virtual.widget,
|
|
resolvedModule: vmod.resolved.widget,
|
|
hashKey: "widgetConfigHash",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
subdirectory: "custom-fields",
|
|
hashGenerator: async (sources) => generateCustomFieldHashes(sources),
|
|
modules: [
|
|
{
|
|
virtualModule: vmod.virtual.link,
|
|
resolvedModule: vmod.resolved.link,
|
|
hashKey: "linkHash",
|
|
},
|
|
{
|
|
virtualModule: vmod.virtual.form,
|
|
resolvedModule: vmod.resolved.form,
|
|
hashKey: "formHash",
|
|
},
|
|
{
|
|
virtualModule: vmod.virtual.display,
|
|
resolvedModule: vmod.resolved.display,
|
|
hashKey: "displayHash",
|
|
},
|
|
],
|
|
},
|
|
]
|