diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index e7787fcf2a..0ce58d378a 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -11,4 +11,9 @@ export default [ description: 'Externalizes the react JSX runtime to help migrate plugins to React 19', scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), }, + { + name: 'add-rspack', + description: 'Converts an existing webpack-based plugin to use rspack as the frontend bundler', + scriptPath: import.meta.resolve('./scripts/add-rspack.js'), + }, ] satisfies Codemod[]; diff --git a/packages/create-plugin/src/codemods/additions/scripts/add-rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/add-rspack.test.ts new file mode 100644 index 0000000000..0f0469824f --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/add-rspack.test.ts @@ -0,0 +1,314 @@ +import { Context } from '../../context.js'; +import addRspack from './add-rspack.js'; + +vi.mock(import('../../../utils/utils.plugin.js'), async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + getPluginJson: () => ({ id: 'my-plugin-id', type: 'panel', info: { author: { name: 'my-author' } } }), + }; +}); + +vi.mock(import('../../../utils/utils.config.js'), async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + getConfig: () => ({ version: '5.0.0', features: {} }), + }; +}); + +vi.mock(import('../../utils.js'), async (importOriginal) => { + const originalModule = await importOriginal(); + const rspackOverrides = { useExperimentalRspack: true, frontendBundler: 'rspack' }; + + // Only render externals.ts from the real template since we assert on its content (RspackOptions). + // All other templates just need a non-empty stub. + const externalsTemplatePath = new URL('../../../../templates/common/.config/bundler/externals.ts', import.meta.url) + .pathname; + const renderedExternals = originalModule.renderTemplate(externalsTemplatePath, true, rspackOverrides); + + return { + ...originalModule, + renderTemplate: (path: string) => { + if (path.includes('.config/bundler/externals.ts')) { + return renderedExternals; + } + return '// rendered template stub'; + }, + }; +}); + +function createBaseContext(): Context { + const context = new Context('/virtual'); + + context.addFile('.config/webpack/webpack.config.ts', ''); + context.addFile('.config/webpack/BuildModeWebpackPlugin.ts', ''); + context.addFile('.config/bundler/externals.ts', ''); + context.addFile('.config/bundler/constants.ts', ''); + context.addFile('.config/bundler/copyFiles.ts', ''); + context.addFile('.config/bundler/utils.ts', ''); + + context.addFile( + 'package.json', + JSON.stringify( + { + scripts: { + build: 'webpack -c ./.config/webpack/webpack.config.ts --env production', + dev: 'webpack -w -c ./.config/webpack/webpack.config.ts --env development', + }, + devDependencies: { + 'copy-webpack-plugin': '^12.0.0', + 'fork-ts-checker-webpack-plugin': '^9.0.0', + 'swc-loader': '^0.2.0', + webpack: '^5.94.0', + 'webpack-cli': '^5.1.4', + 'webpack-livereload-plugin': '^3.0.2', + 'webpack-subresource-integrity': '^5.1.0', + 'webpack-virtual-modules': '^0.6.2', + }, + }, + null, + 2 + ) + ); + context.addFile('.config/.cprc.json', JSON.stringify({ version: '5.0.0', features: {} }, null, 2)); + return context; +} + +describe('add-rspack', () => { + describe('guard clauses', () => { + it('should return unchanged context when rspack config already exists', () => { + const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'rspack config'); + context.addFile('.config/webpack/webpack.config.ts', 'webpack config'); + const changesBefore = Object.keys(context.listChanges()).length; + + const result = addRspack(context); + + expect(Object.keys(result.listChanges()).length).toBe(changesBefore); + }); + + it('should return unchanged context when no webpack config exists', () => { + const context = new Context('/virtual'); + + const result = addRspack(context); + + expect(result.hasChanges()).toBeFalsy(); + }); + }); + + describe('.cprc.json', () => { + it('should update existing .cprc.json with useExperimentalRspack flag', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const cprc = JSON.parse(result.getFile('.config/.cprc.json')!); + + expect(cprc.features.useExperimentalRspack).toBe(true); + }); + + it('should preserve existing .cprc.json properties', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const cprc = JSON.parse(result.getFile('.config/.cprc.json')!); + + expect(cprc.version).toBe('5.0.0'); + }); + }); + + describe('rspack config files', () => { + it('should add rspack config files', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.doesFileExist('.config/rspack/rspack.config.ts')).toBe(true); + expect(result.doesFileExist('.config/rspack/BuildModeRspackPlugin.ts')).toBe(true); + expect(result.doesFileExist('.config/rspack/liveReloadPlugin.ts')).toBe(true); + }); + }); + + describe('bundler files', () => { + it('should update externals.ts with rspack imports', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const externals = result.getFile('.config/bundler/externals.ts')!; + + expect(externals).toContain('RspackOptions'); + }); + + it('should update all bundler files', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + const changes = result.listChanges(); + expect(changes['.config/bundler/externals.ts']?.changeType).toBe('update'); + expect(changes['.config/bundler/constants.ts']?.changeType).toBe('update'); + expect(changes['.config/bundler/copyFiles.ts']?.changeType).toBe('update'); + expect(changes['.config/bundler/utils.ts']?.changeType).toBe('update'); + }); + + it('should add bundler files that do not already exist', () => { + const context = new Context('/virtual'); + context.addFile('.config/webpack/webpack.config.ts', 'webpack config'); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { build: 'webpack', dev: 'webpack -w' }, devDependencies: {} }, null, 2) + ); + + const result = addRspack(context); + + expect(result.doesFileExist('.config/bundler/externals.ts')).toBe(true); + expect(result.doesFileExist('.config/bundler/constants.ts')).toBe(true); + expect(result.doesFileExist('.config/bundler/copyFiles.ts')).toBe(true); + expect(result.doesFileExist('.config/bundler/utils.ts')).toBe(true); + }); + }); + + describe('package.json', () => { + it('should add rspack devDependencies', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['@rspack/core']).toBe('^1.6.0'); + expect(pkg.devDependencies['@rspack/cli']).toBe('^1.6.0'); + expect(pkg.devDependencies['ts-checker-rspack-plugin']).toBe('^1.2.0'); + expect(pkg.devDependencies['rspack-plugin-virtual-module']).toBe('^1.0.0'); + expect(pkg.devDependencies['@types/ws']).toBe('^8.18.1'); + expect(pkg.devDependencies['ws']).toBe('^8.13.0'); + }); + + it('should remove webpack-only devDependencies', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['copy-webpack-plugin']).toBeUndefined(); + expect(pkg.devDependencies['fork-ts-checker-webpack-plugin']).toBeUndefined(); + expect(pkg.devDependencies['swc-loader']).toBeUndefined(); + expect(pkg.devDependencies['webpack-cli']).toBeUndefined(); + expect(pkg.devDependencies['webpack-livereload-plugin']).toBeUndefined(); + expect(pkg.devDependencies['webpack-subresource-integrity']).toBeUndefined(); + expect(pkg.devDependencies['webpack-virtual-modules']).toBeUndefined(); + }); + + it('should keep webpack package itself', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['webpack']).toBe('^5.94.0'); + }); + + it('should update build and dev scripts to use rspack', () => { + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.scripts.build).toBe('rspack -c ./.config/rspack/rspack.config.ts --env production'); + expect(pkg.scripts.dev).toBe('rspack -w -c ./.config/rspack/rspack.config.ts --env development'); + }); + }); + + describe('webpack cleanup', () => { + it('should delete webpack config files from .config/webpack/', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.doesFileExist('.config/webpack/webpack.config.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/BuildModeWebpackPlugin.ts')).toBe(false); + }); + }); + + describe('custom webpack config extension', () => { + it('should create root rspack.config.ts when root webpack.config.ts exists', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'import grafanaConfig from "./.config/webpack/webpack.config";'); + + const result = addRspack(context); + + expect(result.doesFileExist('rspack.config.ts')).toBe(true); + }); + + it('should include throw Error in root rspack.config.ts', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const rspackConfig = result.getFile('rspack.config.ts')!; + + expect(rspackConfig).toContain('throw new Error'); + expect(rspackConfig).toContain('[add-rspack]'); + }); + + it('should reference webpack-merge in migration instructions', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const rspackConfig = result.getFile('rspack.config.ts')!; + + expect(rspackConfig).toContain('webpack-merge'); + }); + + it('should include migration instructions in root rspack.config.ts', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const rspackConfig = result.getFile('rspack.config.ts')!; + + expect(rspackConfig).toContain('TODO'); + expect(rspackConfig).toContain('webpack.config.ts'); + expect(rspackConfig).toContain('.config/rspack/rspack.config'); + }); + + it('should import from .config/rspack/rspack.config in root rspack.config.ts', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const rspackConfig = result.getFile('rspack.config.ts')!; + + expect(rspackConfig).toContain("import grafanaConfig from './.config/rspack/rspack.config'"); + }); + + it('should leave root webpack.config.ts untouched', () => { + const context = createBaseContext(); + const originalContent = 'import grafanaConfig from "./.config/webpack/webpack.config";'; + context.addFile('webpack.config.ts', originalContent); + + const result = addRspack(context); + + expect(result.getFile('webpack.config.ts')).toBe(originalContent); + }); + + it('should point build/dev scripts to root rspack.config.ts when custom config exists', () => { + const context = createBaseContext(); + context.addFile('webpack.config.ts', 'custom webpack config'); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.scripts.build).toBe('rspack -c ./rspack.config.ts --env production'); + expect(pkg.scripts.dev).toBe('rspack -w -c ./rspack.config.ts --env development'); + }); + + it('should not create root rspack.config.ts when no root webpack.config.ts exists', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.doesFileExist('rspack.config.ts')).toBe(false); + }); + }); +}); diff --git a/packages/create-plugin/src/codemods/additions/scripts/add-rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/add-rspack.ts new file mode 100644 index 0000000000..5a80ef4032 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/add-rspack.ts @@ -0,0 +1,181 @@ +import { fileURLToPath } from 'node:url'; +import type { Context } from '../../context.js'; +import { + additionsDebug, + addDependenciesToPackageJson, + readJsonFile, + removeDependenciesFromPackageJson, + renderTemplate, +} from '../../utils.js'; + +const RSPACK_TEMPLATE_DATA_OVERRIDES = { + useExperimentalRspack: true, + frontendBundler: 'rspack', +}; + +const RSPACK_DEV_DEPENDENCIES = { + '@rspack/core': '^1.6.0', + '@rspack/cli': '^1.6.0', + 'ts-checker-rspack-plugin': '^1.2.0', + 'rspack-plugin-virtual-module': '^1.0.0', + '@types/ws': '^8.18.1', + ws: '^8.13.0', +}; + +const WEBPACK_ONLY_DEV_DEPENDENCIES = [ + 'copy-webpack-plugin', + 'fork-ts-checker-webpack-plugin', + 'swc-loader', + 'webpack-livereload-plugin', + 'webpack-subresource-integrity', + 'webpack-virtual-modules', + 'webpack-cli', +]; + +const RSPACK_CONFIG_FILES = [ + '.config/rspack/rspack.config.ts', + '.config/rspack/BuildModeRspackPlugin.ts', + '.config/rspack/liveReloadPlugin.ts', +]; + +const BUNDLER_FILES = [ + '.config/bundler/constants.ts', + '.config/bundler/copyFiles.ts', + '.config/bundler/externals.ts', + '.config/bundler/utils.ts', +]; + +export default function addRspack(context: Context): Context { + if (context.doesFileExist('.config/rspack/rspack.config.ts')) { + additionsDebug('Rspack config already exists. Skipping add-rspack addition.'); + return context; + } + + if (!context.doesFileExist('.config/webpack/webpack.config.ts')) { + additionsDebug('No webpack config found at .config/webpack/webpack.config.ts. Skipping.'); + return context; + } + + addRspackConfigFiles(context); + updateBundlerFiles(context); + updateCprcConfig(context); + + const hasCustomConfig = handleCustomWebpackConfig(context); + + updatePackageJson(context, hasCustomConfig); + deleteWebpackConfigFiles(context); + + return context; +} + +function updateCprcConfig(context: Context): void { + const cprcPath = '.config/.cprc.json'; + if (context.doesFileExist(cprcPath)) { + const config = readJsonFile(context, cprcPath); + + const updated = { + ...config, + features: { + ...config.features, + useExperimentalRspack: true, + }, + }; + + context.updateFile(cprcPath, JSON.stringify(updated, null, 2)); + } +} + +const resolveTemplatePath = (relativePath: string) => + fileURLToPath(new URL(`../../../../templates/common/${relativePath}`, import.meta.url)); + +function addRspackConfigFiles(context: Context): void { + for (const filePath of RSPACK_CONFIG_FILES) { + const rendered = renderTemplate(resolveTemplatePath(filePath), true, RSPACK_TEMPLATE_DATA_OVERRIDES); + context.addFile(filePath, rendered); + } +} + +function updateBundlerFiles(context: Context): void { + for (const filePath of BUNDLER_FILES) { + const rendered = renderTemplate(resolveTemplatePath(filePath), true, RSPACK_TEMPLATE_DATA_OVERRIDES); + context.doesFileExist(filePath) ? context.updateFile(filePath, rendered) : context.addFile(filePath, rendered); + } +} + +function handleCustomWebpackConfig(context: Context): boolean { + const hasCustomConfig = context.doesFileExist('webpack.config.ts'); + + if (!hasCustomConfig) { + return false; + } + + additionsDebug('Custom root webpack.config.ts detected. Creating rspack.config.ts stub with migration instructions.'); + + context.addFile('rspack.config.ts', ROOT_RSPACK_CONFIG_TEMPLATE); + + return true; +} + +interface PackageJson { + scripts: Record; + [key: string]: unknown; +} + +function updatePackageJson(context: Context, hasCustomConfig: boolean): void { + if (!context.doesFileExist('package.json')) { + additionsDebug('No package.json found. Skipping dependency and script updates.'); + return; + } + + addDependenciesToPackageJson(context, {}, RSPACK_DEV_DEPENDENCIES); + removeDependenciesFromPackageJson(context, [], WEBPACK_ONLY_DEV_DEPENDENCIES); + + const packageJson = readJsonFile(context, 'package.json'); + const configPath = hasCustomConfig ? './rspack.config.ts' : './.config/rspack/rspack.config.ts'; + const updatedScripts = { + ...packageJson.scripts, + build: `rspack -c ${configPath} --env production`, + dev: `rspack -w -c ${configPath} --env development`, + }; + const updatedPackageJson = { + ...packageJson, + scripts: updatedScripts, + }; + + context.updateFile('package.json', JSON.stringify(updatedPackageJson, null, 2)); +} + +function deleteWebpackConfigFiles(context: Context): void { + const webpackFiles = context.readDir('.config/webpack'); + + for (const filePath of webpackFiles) { + context.deleteFile(filePath); + } +} + +const ROOT_RSPACK_CONFIG_TEMPLATE = `import type { Configuration } from '@rspack/core'; +import grafanaConfig from './.config/rspack/rspack.config'; + +// TODO: Your plugin extends the default bundler configuration. +// The custom webpack overrides in ./webpack.config.ts need to be +// migrated to this rspack configuration file. +// +// 1. Review your customizations in ./webpack.config.ts +// 2. Apply equivalent rspack configuration below using webpack-merge +// 3. Remove the error below once migration is complete +// 4. Delete ./webpack.config.ts +// +// See: https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations + +throw new Error( + '[add-rspack] This plugin has a custom webpack configuration that needs ' + + 'manual migration to rspack. See the comments in this file for instructions.' +); + +const config = async (env: Record): Promise => { + const baseConfig = await grafanaConfig(env); + return baseConfig; +}; + +export default config; +`; diff --git a/packages/create-plugin/src/codemods/utils.ts b/packages/create-plugin/src/codemods/utils.ts index 491e5ce302..b0f1ce9fc1 100644 --- a/packages/create-plugin/src/codemods/utils.ts +++ b/packages/create-plugin/src/codemods/utils.ts @@ -304,8 +304,12 @@ function sortObjectByKeys>(obj: T): T { export const migrationsDebug = debug.extend('migrations'); export const additionsDebug = debug.extend('additions'); -export function renderTemplate(templatePath: string, includeWarning = false): string { - const templateData = getTemplateData(); +export function renderTemplate( + templatePath: string, + includeWarning = false, + templateDataOverrides: Record = {} +): string { + const templateData = { ...getTemplateData(), ...templateDataOverrides }; let rendered = renderHandlebarsTemplate(readFileSync(templatePath, 'utf-8'), templateData); const regex = /DO NOT EDIT THIS FILE DIRECTLY\./g; if (includeWarning && !regex.test(rendered)) {