From fd9a996e86697b4ff554adc66df2718b06db877b Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 10 Mar 2026 16:04:47 +0100 Subject: [PATCH 01/25] refactor: add templateDataOverrides param to renderTemplate Allow codemods to override template data without re-wiring the full template rendering pipeline. --- packages/create-plugin/src/codemods/utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/create-plugin/src/codemods/utils.ts b/packages/create-plugin/src/codemods/utils.ts index 9a51dd4f79..94bc0bfc12 100644 --- a/packages/create-plugin/src/codemods/utils.ts +++ b/packages/create-plugin/src/codemods/utils.ts @@ -343,8 +343,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)) { From c7333a8ee7d5717de7a5cad9d2d06cb93bc9b9c5 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 16:39:22 +0200 Subject: [PATCH 02/25] feat: make rspack template compatible with rspack 2 Import BannerPlugin, CopyRspackPlugin and SubresourceIntegrityPlugin as named exports from @rspack/core. SubresourceIntegrityPlugin moved out of rspack.experiments in rspack 2; the top-level export also exists in rspack 1.7, so the template keeps working for current users. --- .../templates/common/.config/rspack/rspack.config.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts index 80b899a0ac..61ab2b1416 100644 --- a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -5,7 +5,7 @@ * https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-webpack-config */ -import rspack, { type Configuration } from '@rspack/core'; +import { BannerPlugin, CopyRspackPlugin, SubresourceIntegrityPlugin, type Configuration } from '@rspack/core'; import ESLintPlugin from 'eslint-webpack-plugin'; import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin'; import path from 'path'; @@ -20,7 +20,6 @@ import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, isWSL } import { externals } from '../bundler/externals.ts'; import { copyFilePatterns } from '../bundler/copyFiles.ts'; -const { SubresourceIntegrityPlugin } = rspack.experiments; const pluginJson = getPluginJson(); const cpVersion = getCPConfigVersion(); const virtualPublicPath = new RspackVirtualModulePlugin({ @@ -154,12 +153,12 @@ const config = async (env): Promise => { new BuildModeRspackPlugin(), virtualPublicPath, // Insert create plugin version information into the bundle - new rspack.BannerPlugin({ + new BannerPlugin({ banner: '/* [create-plugin] version: ' + cpVersion + ' */', raw: true, entryOnly: true, }), - new rspack.CopyRspackPlugin({ + new CopyRspackPlugin({ patterns: copyFilePatterns, }), // Replace certain template-variables in the README and plugin.json From d74a1d71e119b9a25bb76db9a9f5148a518297c2 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 16:41:04 +0200 Subject: [PATCH 03/25] feat: upgrade experimental rspack template dependencies to rspack 2 Bump @rspack/core and @rspack/cli to ^2.2.0, add rspack-merge for extending the config, and raise ts-checker-rspack-plugin to ^1.4.0, the first release whose peer range includes @rspack/core 2. --- packages/create-plugin/templates/common/_package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/create-plugin/templates/common/_package.json b/packages/create-plugin/templates/common/_package.json index ad7f33ba29..35f0e8793d 100644 --- a/packages/create-plugin/templates/common/_package.json +++ b/packages/create-plugin/templates/common/_package.json @@ -21,8 +21,8 @@ "@grafana/sign-plugin": "^3.3.3", "@grafana/tsconfig": "^2.2.0", "@playwright/test": "^1.62.1",{{#if useExperimentalRspack}} - "@rspack/core": "^1.6.0", - "@rspack/cli": "^1.6.0",{{/if}} + "@rspack/core": "^2.2.0", + "@rspack/cli": "^2.2.0",{{/if}} "@stylistic/eslint-plugin": "^5.10.0", "@swc/core": "^1.15.0", "@swc/helpers": "^0.5.0", @@ -52,6 +52,7 @@ "jest-environment-jsdom": "^29.7.0", "prettier": "^3.6.0", "replace-in-file-webpack-plugin": "^1.0.0",{{#if useExperimentalRspack}} + "rspack-merge": "^1.0.1", "rspack-plugin-virtual-module": "^1.0.0",{{/if}} "sass": "^1.89.0", "sass-loader": "^16.0.0", @@ -60,7 +61,7 @@ "swc-loader": "^0.2.0",{{/unless}} "terser-webpack-plugin": "^5.3.0", "ts-node": "^10.9.0",{{#if useExperimentalRspack}} - "ts-checker-rspack-plugin": "^1.2.0",{{/if}} + "ts-checker-rspack-plugin": "^1.4.0",{{/if}} "typescript": "5.9.2", "webpack": "^5.101.0"{{#unless useExperimentalRspack}}, "webpack-cli": "^6.0.0", From 05c594bf15e0bb1f7a48809c7427237f3cc4d3c2 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:03:31 +0200 Subject: [PATCH 04/25] fix: align rspack template with webpack template Bring over changes made to the webpack template after the rspack one was added: the exported Env type, the plugin id and version banner line, and ReplaceInFile test patterns so nested plugin.json files get their placeholders replaced. Also read the plugin id and version once. --- .../common/.config/rspack/rspack.config.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts index 61ab2b1416..aa0d4c15d1 100644 --- a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -21,7 +21,9 @@ import { externals } from '../bundler/externals.ts'; import { copyFilePatterns } from '../bundler/copyFiles.ts'; const pluginJson = getPluginJson(); +const pluginId = pluginJson.id; const cpVersion = getCPConfigVersion(); +const pluginVersion = getPackageJson().version; const virtualPublicPath = new RspackVirtualModulePlugin({ 'grafana-public-path': ` import amdMetaModule from 'amd-module'; @@ -29,11 +31,15 @@ import amdMetaModule from 'amd-module'; __webpack_public_path__ = amdMetaModule && amdMetaModule.uri ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) - : 'public/plugins/${pluginJson.id}/'; + : 'public/plugins/${pluginId}/'; `, }); -const config = async (env): Promise => { +export type Env = { + [key: string]: true | string | Env; +}; + +const config = async (env: Env): Promise => { const baseConfig: Configuration = { context: path.join(process.cwd(), SOURCE_DIR), @@ -144,8 +150,8 @@ const config = async (env): Promise => { type: 'amd', }, path: path.resolve(process.cwd(), DIST_DIR), - publicPath: `public/plugins/${pluginJson.id}/`, - uniqueName: pluginJson.id, + publicPath: `public/plugins/${pluginId}/`, + uniqueName: pluginId, crossOriginLoading: 'anonymous', }, @@ -154,7 +160,8 @@ const config = async (env): Promise => { virtualPublicPath, // Insert create plugin version information into the bundle new BannerPlugin({ - banner: '/* [create-plugin] version: ' + cpVersion + ' */', + banner: `/* [create-plugin] version: ${cpVersion} */ + /* [create-plugin] plugin: ${pluginId}@${pluginVersion} */`, raw: true, entryOnly: true, }), @@ -165,11 +172,11 @@ const config = async (env): Promise => { new ReplaceInFileWebpackPlugin([ { dir: DIST_DIR, - files: ['plugin.json', 'README.md'], + test: [/(^|\/)plugin\.json$/, /(^|\/)README\.md$/], rules: [ { search: /\%VERSION\%/g, - replace: getPackageJson().version, + replace: pluginVersion, }, { search: /\%TODAY\%/g, @@ -177,7 +184,7 @@ const config = async (env): Promise => { }, { search: /\%PLUGIN_ID\%/g, - replace: pluginJson.id, + replace: pluginId, }, ], }, @@ -198,6 +205,7 @@ const config = async (env): Promise => { new ESLintPlugin({ extensions: ['.ts', '.tsx'], lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files + failOnError: Boolean(env.production), }), ] : []), From 872ba09783034e60b3e02b9eea61189b79659d57 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:09:54 +0200 Subject: [PATCH 05/25] fix: use eslint-rspack-plugin in the rspack template eslint-webpack-plugin relies on compilation hooks rspack does not call, so it never reported lint errors in rspack dev builds. eslint-rspack-plugin reports them and keeps watch running by default. It removed failOnError, which had no effect anyway because the plugin only runs in development. --- .../templates/common/.config/rspack/rspack.config.ts | 3 +-- packages/create-plugin/templates/common/_package.json | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts index aa0d4c15d1..5639291fc1 100644 --- a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -6,7 +6,7 @@ */ import { BannerPlugin, CopyRspackPlugin, SubresourceIntegrityPlugin, type Configuration } from '@rspack/core'; -import ESLintPlugin from 'eslint-webpack-plugin'; +import ESLintPlugin from 'eslint-rspack-plugin'; import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin'; import path from 'path'; import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; @@ -205,7 +205,6 @@ const config = async (env: Env): Promise => { new ESLintPlugin({ extensions: ['.ts', '.tsx'], lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files - failOnError: Boolean(env.production), }), ] : []), diff --git a/packages/create-plugin/templates/common/_package.json b/packages/create-plugin/templates/common/_package.json index 35f0e8793d..ae71704d62 100644 --- a/packages/create-plugin/templates/common/_package.json +++ b/packages/create-plugin/templates/common/_package.json @@ -42,8 +42,9 @@ "eslint-config-prettier": "^10.1.0", "eslint-plugin-jsdoc": "^52.0.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.1.0", - "eslint-webpack-plugin": "^5.0.0",{{#unless useExperimentalRspack}} + "eslint-plugin-react-hooks": "^7.1.0",{{#if useExperimentalRspack}} + "eslint-rspack-plugin": "^5.0.1",{{/if}}{{#unless useExperimentalRspack}} + "eslint-webpack-plugin": "^5.0.0", "fork-ts-checker-webpack-plugin": "^9.1.0",{{/unless}} "glob": "^11.1.0", "identity-obj-proxy": "^3.0.0", From ac4fb5ed91f4df19788da402db7d20f2195c209f Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:15:28 +0200 Subject: [PATCH 06/25] feat: require node >=22.23 in scaffolded plugins Rspack 2 needs Node 22.12 or later. Use the latest 22 minor so type stripping, enabled by default from 22.18, is always available and the rspack CLI loads TypeScript configs natively. --- packages/create-plugin/templates/common/_package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-plugin/templates/common/_package.json b/packages/create-plugin/templates/common/_package.json index ae71704d62..eae22de6e2 100644 --- a/packages/create-plugin/templates/common/_package.json +++ b/packages/create-plugin/templates/common/_package.json @@ -72,7 +72,7 @@ "ws": "^8.13.0"{{/if}} }, "engines": { - "node": ">=22" + "node": ">=22.23" }, "dependencies": { "@emotion/css": "11.10.6", From bcfebc1dc120852b357be173f9ede3ee15bb7ff2 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:19:32 +0200 Subject: [PATCH 07/25] refactor: port add-rspack addition script as rspack Bring the addition over from the add-rspack branch unchanged apart from the name. It is not registered yet. The bundler files test now checks the rendered content instead of the change type, because Context keeps 'add' for files added earlier in the same run. --- .../codemods/additions/scripts/rspack.test.ts | 313 ++++++++++++++++++ .../src/codemods/additions/scripts/rspack.ts | 181 ++++++++++ 2 files changed, 494 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts create mode 100644 packages/create-plugin/src/codemods/additions/scripts/rspack.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts new file mode 100644 index 0000000000..82304ae68e --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -0,0 +1,313 @@ +import { Context } from '../../context.js'; +import addRspack from './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('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 re-render all bundler files', () => { + const context = createBaseContext(); + + const result = addRspack(context); + + expect(result.getFile('.config/bundler/externals.ts')).toContain('RspackOptions'); + expect(result.getFile('.config/bundler/constants.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/bundler/copyFiles.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/bundler/utils.ts')).toBe('// rendered template stub'); + }); + + 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/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts new file mode 100644 index 0000000000..5a80ef4032 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/scripts/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; +`; From 62ad4de088f0c2c04734fa25b2c23916fe63168c Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:24:23 +0200 Subject: [PATCH 08/25] feat: take rspack addition dependencies from the package.json template Render the package.json template for rspack and for webpack, and use the difference as the dependencies to add and remove. The addition then installs rspack 2 and eslint-rspack-plugin, removes eslint-webpack-plugin, and follows future template version bumps without its own list. --- .../codemods/additions/scripts/rspack.test.ts | 84 +++++++++++++------ .../src/codemods/additions/scripts/rspack.ts | 49 ++++++----- 2 files changed, 88 insertions(+), 45 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 82304ae68e..9b01788606 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -5,7 +5,12 @@ 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' } } }), + getPluginJson: () => ({ + id: 'my-plugin-id', + name: 'My Plugin', + type: 'panel', + info: { author: { name: 'my-author' } }, + }), }; }); @@ -19,25 +24,30 @@ vi.mock(import('../../../utils/utils.config.js'), async (importOriginal) => { 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). + // Only render externals.ts and _package.json from the real templates since we assert on their content. // 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; + renderTemplate: (path: string, includeWarning?: boolean, templateDataOverrides?: Record) => { + if (path.includes('.config/bundler/externals.ts') || path.endsWith('_package.json')) { + return originalModule.renderTemplate(path, includeWarning, templateDataOverrides); } return '// rendered template stub'; }, }; }); +const RSPACK_TEMPLATE_DATA = { useExperimentalRspack: true, frontendBundler: 'rspack' }; +const WEBPACK_TEMPLATE_DATA = { useExperimentalRspack: false, frontendBundler: 'webpack' }; + +async function getTemplateDevDependencies(templateData: Record): Promise> { + const { renderTemplate } = await vi.importActual('../../utils.js'); + const packageJsonTemplatePath = new URL('../../../../templates/common/_package.json', import.meta.url).pathname; + const { devDependencies } = JSON.parse(renderTemplate(packageJsonTemplatePath, false, templateData)); + return devDependencies; +} + function createBaseContext(): Context { const context = new Context('/virtual'); @@ -168,33 +178,55 @@ describe('rspack', () => { }); describe('package.json', () => { - it('should add rspack devDependencies', () => { + it('should add the rspack-only devDependencies from the template at the template versions', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const webpackDeps = await getTemplateDevDependencies(WEBPACK_TEMPLATE_DATA); + const rspackOnlyDeps = Object.keys(rspackDeps).filter((name) => !(name in webpackDeps)); 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'); + expect(rspackOnlyDeps).toEqual(expect.arrayContaining(['@rspack/core', '@rspack/cli', 'eslint-rspack-plugin'])); + for (const name of rspackOnlyDeps) { + expect(pkg.devDependencies[name], name).toBe(rspackDeps[name]); + } }); - it('should remove webpack-only devDependencies', () => { + it('should upgrade rspack devDependencies that are below the template version', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.devDependencies['@rspack/core'] = '^1.6.0'; + pkg.devDependencies['@rspack/cli'] = '^1.6.0'; + pkg.devDependencies['ts-checker-rspack-plugin'] = '^1.2.0'; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); const result = addRspack(context); - const pkg = JSON.parse(result.getFile('package.json')!); + const updated = 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(); + expect(updated.devDependencies['@rspack/core']).toBe(rspackDeps['@rspack/core']); + expect(updated.devDependencies['@rspack/cli']).toBe(rspackDeps['@rspack/cli']); + expect(updated.devDependencies['ts-checker-rspack-plugin']).toBe(rspackDeps['ts-checker-rspack-plugin']); + }); + + it('should remove webpack-only devDependencies', () => { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.devDependencies['eslint-webpack-plugin'] = '^5.0.0'; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + + const result = addRspack(context); + const updated = JSON.parse(result.getFile('package.json')!); + + expect(updated.devDependencies['copy-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['eslint-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['fork-ts-checker-webpack-plugin']).toBeUndefined(); + expect(updated.devDependencies['swc-loader']).toBeUndefined(); + expect(updated.devDependencies['webpack-cli']).toBeUndefined(); + expect(updated.devDependencies['webpack-livereload-plugin']).toBeUndefined(); + expect(updated.devDependencies['webpack-subresource-integrity']).toBeUndefined(); + expect(updated.devDependencies['webpack-virtual-modules']).toBeUndefined(); }); it('should keep webpack package itself', () => { diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 5a80ef4032..3171f8b895 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -13,25 +13,11 @@ const RSPACK_TEMPLATE_DATA_OVERRIDES = { 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_TEMPLATE_DATA_OVERRIDES = { + useExperimentalRspack: false, + frontendBundler: 'webpack', }; -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', @@ -127,8 +113,9 @@ function updatePackageJson(context: Context, hasCustomConfig: boolean): void { return; } - addDependenciesToPackageJson(context, {}, RSPACK_DEV_DEPENDENCIES); - removeDependenciesFromPackageJson(context, [], WEBPACK_ONLY_DEV_DEPENDENCIES); + const { rspackOnlyDevDependencies, webpackOnlyDevDependencies } = getBundlerDevDependencies(); + addDependenciesToPackageJson(context, {}, rspackOnlyDevDependencies); + removeDependenciesFromPackageJson(context, [], webpackOnlyDevDependencies); const packageJson = readJsonFile(context, 'package.json'); const configPath = hasCustomConfig ? './rspack.config.ts' : './.config/rspack/rspack.config.ts'; @@ -145,6 +132,30 @@ function updatePackageJson(context: Context, hasCustomConfig: boolean): void { context.updateFile('package.json', JSON.stringify(updatedPackageJson, null, 2)); } +// The rspack and webpack renders of the package.json template are the source of truth for which +// dev dependencies each bundler needs, so the addition never drifts from what `generate` scaffolds +function getBundlerDevDependencies() { + const templatePath = resolveTemplatePath('_package.json'); + const rspackDevDependencies = renderDevDependencies(templatePath, RSPACK_TEMPLATE_DATA_OVERRIDES); + const webpackDevDependencies = renderDevDependencies(templatePath, WEBPACK_TEMPLATE_DATA_OVERRIDES); + + const rspackOnlyDevDependencies = Object.fromEntries( + Object.entries(rspackDevDependencies).filter(([name]) => !(name in webpackDevDependencies)) + ); + const webpackOnlyDevDependencies = Object.keys(webpackDevDependencies).filter( + (name) => !(name in rspackDevDependencies) + ); + + return { rspackOnlyDevDependencies, webpackOnlyDevDependencies }; +} + +function renderDevDependencies(templatePath: string, templateData: Record): Record { + const rendered: { devDependencies?: Record } = JSON.parse( + renderTemplate(templatePath, false, templateData) + ); + return rendered.devDependencies ?? {}; +} + function deleteWebpackConfigFiles(context: Context): void { const webpackFiles = context.readDir('.config/webpack'); From 31aa4bde4f339cedd05fcdb24af2b83e217d653e Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:25:30 +0200 Subject: [PATCH 09/25] feat: handle every starting point in the rspack addition Re-render the rspack and bundler templates on every run instead of skipping when .config/rspack exists, so stale rspack folders and the experimental rspack setup are replaced too. Delete only the files create-plugin scaffolded into .config/webpack and report the rest, and explain when there is no bundler configuration to migrate. --- .../codemods/additions/scripts/rspack.test.ts | 53 +++++++++-- .../src/codemods/additions/scripts/rspack.ts | 91 ++++++++++++++----- 2 files changed, 111 insertions(+), 33 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 9b01788606..4b49536d02 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -86,24 +86,43 @@ function createBaseContext(): Context { } describe('rspack', () => { - describe('guard clauses', () => { - it('should return unchanged context when rspack config already exists', () => { + describe('start states', () => { + it('should make no changes and explain why when there is no create-plugin bundler setup', () => { 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); + expect(result.hasChanges()).toBe(false); + expect(result.getMessage()).toEqual( + expect.objectContaining({ level: 'warning', title: expect.stringContaining('bundler configuration') }) + ); + }); + + it('should re-render a stale .config/rspack left behind on a webpack plugin', () => { + const context = createBaseContext(); + context.addFile('.config/rspack/rspack.config.ts', 'stale rspack config'); + + const result = addRspack(context); + + expect(result.getFile('.config/rspack/rspack.config.ts')).toBe('// rendered template stub'); + expect(result.doesFileExist('.config/webpack/webpack.config.ts')).toBe(false); }); - it('should return unchanged context when no webpack config exists', () => { + it('should replace an experimental rspack setup with the current rspack templates', () => { const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'experimental rspack config'); + context.addFile('.config/rspack/BuildModeRspackPlugin.ts', 'experimental build mode plugin'); + context.addFile('.config/rspack/liveReloadPlugin.ts', 'experimental live reload plugin'); + context.addFile( + '.config/.cprc.json', + JSON.stringify({ version: '7.9.0', features: { useExperimentalRspack: true } }, null, 2) + ); const result = addRspack(context); - expect(result.hasChanges()).toBeFalsy(); + expect(result.getFile('.config/rspack/rspack.config.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/rspack/BuildModeRspackPlugin.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/rspack/liveReloadPlugin.ts')).toBe('// rendered template stub'); }); }); @@ -250,13 +269,29 @@ describe('rspack', () => { }); describe('webpack cleanup', () => { - it('should delete webpack config files from .config/webpack/', () => { + it('should delete the create-plugin webpack files from .config/webpack/', () => { const context = createBaseContext(); + context.addFile('.config/webpack/utils.ts', ''); + context.addFile('.config/webpack/constants.ts', ''); + context.addFile('.config/webpack/tsconfig.webpack.json', ''); const result = addRspack(context); expect(result.doesFileExist('.config/webpack/webpack.config.ts')).toBe(false); expect(result.doesFileExist('.config/webpack/BuildModeWebpackPlugin.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/utils.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/constants.ts')).toBe(false); + expect(result.doesFileExist('.config/webpack/tsconfig.webpack.json')).toBe(false); + }); + + it('should keep files the plugin added to .config/webpack/ and report them', () => { + const context = createBaseContext(); + context.addFile('.config/webpack/CorsWorkerPlugin.ts', 'export class CorsWorkerPlugin {}'); + + const result = addRspack(context); + + expect(result.getFile('.config/webpack/CorsWorkerPlugin.ts')).toBe('export class CorsWorkerPlugin {}'); + expect(result.getMessage()?.body?.join('\n')).toContain('.config/webpack/CorsWorkerPlugin.ts'); }); }); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 3171f8b895..79696df18a 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -31,29 +31,64 @@ const BUNDLER_FILES = [ '.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; - } +const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts'; +const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts'; + +// every file create-plugin has scaffolded into .config/webpack over time. anything else in there +// was added by the plugin author, so it is kept and reported instead of deleted +const WEBPACK_TEMPLATE_FILES = [ + '.config/webpack/BuildModeWebpackPlugin.ts', + '.config/webpack/PluginSchemaWebpackPlugin.ts', + '.config/webpack/constants.ts', + '.config/webpack/generateCode.ts', + '.config/webpack/publicPath.ts', + '.config/webpack/tsconfig.webpack.json', + '.config/webpack/utils.ts', + '.config/webpack/watchPluginJson.ts', + '.config/webpack/webpack.config.ts', + '.config/webpack/webpack.parts.ts', +]; + +export default function rspack(context: Context): Context { + const hasWebpackSetup = context.doesFileExist(WEBPACK_CONFIG_PATH); + const hasRspackSetup = context.doesFileExist(RSPACK_CONFIG_PATH); - if (!context.doesFileExist('.config/webpack/webpack.config.ts')) { - additionsDebug('No webpack config found at .config/webpack/webpack.config.ts. Skipping.'); + if (!hasWebpackSetup && !hasRspackSetup) { + context.setMessage({ + level: 'warning', + title: 'No create-plugin bundler configuration found, so nothing was changed.', + body: [`The rspack addition expects ${WEBPACK_CONFIG_PATH} or ${RSPACK_CONFIG_PATH} to exist.`], + }); return context; } - addRspackConfigFiles(context); - updateBundlerFiles(context); + const followUps: string[] = []; + + renderTemplateFiles(context, RSPACK_CONFIG_FILES); + renderTemplateFiles(context, BUNDLER_FILES); updateCprcConfig(context); const hasCustomConfig = handleCustomWebpackConfig(context); updatePackageJson(context, hasCustomConfig); - deleteWebpackConfigFiles(context); + followUps.push(...deleteWebpackTemplateFiles(context)); + reportFollowUps(context, followUps); return context; } +function reportFollowUps(context: Context, followUps: string[]): void { + if (followUps.length === 0) { + return; + } + + context.setMessage({ + level: 'warning', + title: 'The rspack addition left some things for you to review.', + body: followUps, + }); +} + function updateCprcConfig(context: Context): void { const cprcPath = '.config/.cprc.json'; if (context.doesFileExist(cprcPath)) { @@ -74,17 +109,15 @@ function updateCprcConfig(context: Context): void { 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) { +// updateFile is a no-op for identical content, so re-rendering on every run keeps the addition idempotent +function renderTemplateFiles(context: Context, filePaths: string[]): void { + for (const filePath of filePaths) { const rendered = renderTemplate(resolveTemplatePath(filePath), true, RSPACK_TEMPLATE_DATA_OVERRIDES); - context.doesFileExist(filePath) ? context.updateFile(filePath, rendered) : context.addFile(filePath, rendered); + if (context.doesFileExist(filePath)) { + context.updateFile(filePath, rendered); + } else { + context.addFile(filePath, rendered); + } } } @@ -156,12 +189,22 @@ function renderDevDependencies(templatePath: string, templateData: Record ` ${filePath}`), + ]; } const ROOT_RSPACK_CONFIG_TEMPLATE = `import type { Configuration } from '@rspack/core'; From 729f6211849d30e8cbe55f81f28aa0793b6d685a Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:26:17 +0200 Subject: [PATCH 10/25] feat: keep webpack packages the root build config still uses Removing copy-webpack-plugin or swc-loader while a root webpack config still imports them breaks the port before it starts. Scan root level webpack.* and rspack.* files for each webpack-only package, keep the ones they reference, and list them for removal after the port. --- .../codemods/additions/scripts/rspack.test.ts | 29 ++++++++++++++ .../src/codemods/additions/scripts/rspack.ts | 40 +++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 4b49536d02..c511106f0e 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -248,6 +248,35 @@ describe('rspack', () => { expect(updated.devDependencies['webpack-virtual-modules']).toBeUndefined(); }); + it('should keep webpack-only devDependencies that root build files still use and report them', () => { + const context = createBaseContext(); + context.addFile( + 'webpack.config.ts', + "import CopyWebpackPlugin from 'copy-webpack-plugin';\nimport { getAlias } from './webpack.config.utils';" + ); + context.addFile('webpack.config.utils.ts', "export const rule = { use: { loader: 'swc-loader' } };"); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['copy-webpack-plugin']).toBe('^12.0.0'); + expect(pkg.devDependencies['swc-loader']).toBe('^0.2.0'); + expect(pkg.devDependencies['webpack-livereload-plugin']).toBeUndefined(); + const report = result.getMessage()?.body?.join('\n'); + expect(report).toContain('copy-webpack-plugin'); + expect(report).toContain('swc-loader'); + }); + + it('should not treat source files as build files when deciding which dependencies to keep', () => { + const context = createBaseContext(); + context.addFile('src/module.ts', "import 'copy-webpack-plugin';"); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['copy-webpack-plugin']).toBeUndefined(); + }); + it('should keep webpack package itself', () => { const context = createBaseContext(); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 79696df18a..66d62e2572 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -70,7 +70,7 @@ export default function rspack(context: Context): Context { const hasCustomConfig = handleCustomWebpackConfig(context); - updatePackageJson(context, hasCustomConfig); + followUps.push(...updatePackageJson(context, hasCustomConfig)); followUps.push(...deleteWebpackTemplateFiles(context)); reportFollowUps(context, followUps); @@ -140,15 +140,29 @@ interface PackageJson { [key: string]: unknown; } -function updatePackageJson(context: Context, hasCustomConfig: boolean): void { +function updatePackageJson(context: Context, hasCustomConfig: boolean): string[] { if (!context.doesFileExist('package.json')) { additionsDebug('No package.json found. Skipping dependency and script updates.'); - return; + return []; } + const followUps: string[] = []; const { rspackOnlyDevDependencies, webpackOnlyDevDependencies } = getBundlerDevDependencies(); + const userBuildFileSources = readUserBuildFileSources(context); + const stillUsedDevDependencies = webpackOnlyDevDependencies.filter((name) => + userBuildFileSources.some((source) => referencesPackage(source, name)) + ); + const unusedDevDependencies = webpackOnlyDevDependencies.filter((name) => !stillUsedDevDependencies.includes(name)); + addDependenciesToPackageJson(context, {}, rspackOnlyDevDependencies); - removeDependenciesFromPackageJson(context, [], webpackOnlyDevDependencies); + removeDependenciesFromPackageJson(context, [], unusedDevDependencies); + + if (stillUsedDevDependencies.length > 0) { + followUps.push( + 'These webpack packages were kept because your root build config still uses them. Remove them once the config is ported to rspack:', + ...stillUsedDevDependencies.map((name) => ` ${name}`) + ); + } const packageJson = readJsonFile(context, 'package.json'); const configPath = hasCustomConfig ? './rspack.config.ts' : './.config/rspack/rspack.config.ts'; @@ -163,6 +177,24 @@ function updatePackageJson(context: Context, hasCustomConfig: boolean): void { }; context.updateFile('package.json', JSON.stringify(updatedPackageJson, null, 2)); + + return followUps; +} + +// root level webpack.* and rspack.* files, e.g. webpack.config.ts and helpers like webpack.config.utils.ts +const USER_BUILD_FILE_PATTERN = /^(webpack|rspack)\.[\w.-]*\.[cm]?[jt]s$/; + +function readUserBuildFileSources(context: Context): string[] { + return context + .readDir('.') + .filter((filePath) => USER_BUILD_FILE_PATTERN.test(filePath)) + .map((filePath) => context.getFile(filePath) ?? ''); +} + +// matches the package as a module specifier or loader string: 'name', "name", `name` or 'name/subpath' +function referencesPackage(source: string, packageName: string): boolean { + const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`['"\`]${escapedName}(/[^'"\`]*)?['"\`]`).test(source); } // The rspack and webpack renders of the package.json template are the source of truth for which From 275a2bbb3c992ece3304a9338120b0ac17181a2e Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:27:28 +0200 Subject: [PATCH 11/25] feat: rewrite webpack calls in every script instead of replacing build and dev Replacing build and dev wholesale dropped chained commands, extra flags and sub scripts like build:compile. Swap the webpack command and config arguments in place across all scripts, map each config path to its rspack counterpart, and report scripts that still run webpack in a way the addition cannot rewrite. --- .../codemods/additions/scripts/rspack.test.ts | 94 ++++++++++++++++--- .../src/codemods/additions/scripts/rspack.ts | 63 +++++++++---- 2 files changed, 130 insertions(+), 27 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index c511106f0e..d4dedc699a 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -297,6 +297,89 @@ describe('rspack', () => { }); }); + describe('scripts', () => { + function runWithScripts(scripts: Record, rootConfig = true) { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.scripts = scripts; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + if (rootConfig) { + context.addFile('webpack.config.ts', 'custom webpack config'); + } + const result = addRspack(context); + return { + scripts: JSON.parse(result.getFile('package.json')!).scripts, + report: result.getMessage()?.body?.join('\n') ?? '', + }; + } + + it('should point scripts that use the root webpack config at the root rspack config', () => { + const { scripts } = runWithScripts({ + build: 'webpack -c webpack.config.ts --env production', + dev: 'webpack -w --config=./webpack.config.ts --env development', + }); + + expect(scripts.build).toBe('rspack -c ./rspack.config.ts --env production'); + expect(scripts.dev).toBe('rspack -w --config=./rspack.config.ts --env development'); + }); + + it('should keep chained commands, env assignments and extra flags', () => { + const { scripts } = runWithScripts({ + build: + 'pnpm run build:deps && NODE_ENV=production npx webpack -c ./webpack.config.ts --env production --stats-error-details', + }); + + expect(scripts.build).toBe( + 'pnpm run build:deps && NODE_ENV=production npx rspack -c ./rspack.config.ts --env production --stats-error-details' + ); + }); + + it('should rewrite sub scripts and leave script runners alone', () => { + const { scripts } = runWithScripts({ + build: 'run-s "build:*"', + 'build:compile': 'webpack -c ./webpack.config.ts --env production', + }); + + expect(scripts.build).toBe('run-s "build:*"'); + expect(scripts['build:compile']).toBe('rspack -c ./rspack.config.ts --env production'); + }); + + it('should rewrite webpack calls that rely on the default config lookup', () => { + const { scripts } = runWithScripts({ build: 'webpack --env production' }); + + expect(scripts.build).toBe('rspack --env production'); + }); + + it('should leave scripts it cannot rewrite unchanged and report them', () => { + const unparseable = 'tsx node_modules/webpack-cli/bin/cli.js -c ./webpack.config.ts --env production'; + const { scripts, report } = runWithScripts({ build: unparseable }); + + expect(scripts.build).toBe(unparseable); + expect(report).toContain('build'); + expect(report).toContain(unparseable); + }); + + it('should not touch or report scripts that only mention webpack tooling', () => { + const { scripts, report } = runWithScripts({ analyze: 'webpack-bundle-analyzer dist/stats.json' }); + + expect(scripts.analyze).toBe('webpack-bundle-analyzer dist/stats.json'); + expect(report).not.toContain('webpack-bundle-analyzer'); + }); + + it('should leave scripts that already call rspack unchanged', () => { + const { scripts } = runWithScripts( + { + build: 'rspack -c ./rspack.config.ts --env production', + dev: 'rspack -w -c ./rspack.config.ts --env development', + }, + false + ); + + expect(scripts.build).toBe('rspack -c ./rspack.config.ts --env production'); + expect(scripts.dev).toBe('rspack -w -c ./rspack.config.ts --env development'); + }); + }); + describe('webpack cleanup', () => { it('should delete the create-plugin webpack files from .config/webpack/', () => { const context = createBaseContext(); @@ -387,17 +470,6 @@ describe('rspack', () => { 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(); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 66d62e2572..4120b6719d 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -68,9 +68,9 @@ export default function rspack(context: Context): Context { renderTemplateFiles(context, BUNDLER_FILES); updateCprcConfig(context); - const hasCustomConfig = handleCustomWebpackConfig(context); + handleCustomWebpackConfig(context); - followUps.push(...updatePackageJson(context, hasCustomConfig)); + followUps.push(...updatePackageJson(context)); followUps.push(...deleteWebpackTemplateFiles(context)); reportFollowUps(context, followUps); @@ -136,11 +136,11 @@ function handleCustomWebpackConfig(context: Context): boolean { } interface PackageJson { - scripts: Record; + scripts?: Record; [key: string]: unknown; } -function updatePackageJson(context: Context, hasCustomConfig: boolean): string[] { +function updatePackageJson(context: Context): string[] { if (!context.doesFileExist('package.json')) { additionsDebug('No package.json found. Skipping dependency and script updates.'); return []; @@ -165,22 +165,53 @@ function updatePackageJson(context: Context, hasCustomConfig: boolean): string[] } 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)); + const { scripts, unparsedScripts } = rewriteWebpackScripts(packageJson.scripts ?? {}); + + if (packageJson.scripts) { + context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2)); + } + + if (unparsedScripts.length > 0) { + followUps.push( + 'These scripts still call webpack in a way the addition could not rewrite. Change them to call rspack with ./rspack.config.ts or ./.config/rspack/rspack.config.ts:', + ...unparsedScripts.map(([name, command]) => ` ${name}: ${command}`) + ); + } return followUps; } +// a webpack or webpack-cli command at the start of a script, after a shell separator, after env +// assignments or after npx. the lookbehind keeps the separator and prefixes out of the match +const WEBPACK_COMMAND_PATTERN = /(?<=(?:^|&&|\|\||;|\|)\s*(?:\w+=\S*\s+)*(?:npx\s+)?)webpack(?:-cli)?(?=\s|$)/g; +const ROOT_WEBPACK_CONFIG_ARG_PATTERN = + /((?:^|\s)(?:-c|--config)(?:\s+|=))(?:\.\/)?webpack\.config\.[cm]?[jt]s(?=\s|$)/g; +const TEMPLATE_WEBPACK_CONFIG_ARG_PATTERN = + /((?:^|\s)(?:-c|--config)(?:\s+|=))(?:\.\/)?\.config\/webpack\/webpack\.config\.ts(?=\s|$)/g; +// anything that still runs webpack after the rewrite, e.g. node_modules/webpack-cli/bin/cli.js +const REMAINING_WEBPACK_PATTERN = /(?:^|[\s/])webpack(?:-cli)?(?:[\s/]|$)|\.config\/webpack\//; + +function rewriteWebpackScripts(scripts: Record) { + const unparsedScripts: Array<[string, string]> = []; + const rewrittenScripts = Object.fromEntries( + Object.entries(scripts).map(([name, command]) => { + const rewritten = command + .replace(WEBPACK_COMMAND_PATTERN, 'rspack') + .replace(ROOT_WEBPACK_CONFIG_ARG_PATTERN, '$1./rspack.config.ts') + .replace(TEMPLATE_WEBPACK_CONFIG_ARG_PATTERN, '$1./.config/rspack/rspack.config.ts'); + + if (REMAINING_WEBPACK_PATTERN.test(rewritten)) { + unparsedScripts.push([name, command]); + return [name, command]; + } + + return [name, rewritten]; + }) + ); + + return { scripts: rewrittenScripts, unparsedScripts }; +} + // root level webpack.* and rspack.* files, e.g. webpack.config.ts and helpers like webpack.config.utils.ts const USER_BUILD_FILE_PATTERN = /^(webpack|rspack)\.[\w.-]*\.[cm]?[jt]s$/; From 1743c9310516e576b6823edee7560573f18cf625 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:28:29 +0200 Subject: [PATCH 12/25] feat: guard the root rspack config stub and point it at rspack-merge Detect any root webpack.config extension, never overwrite an existing root rspack config (addFile threw on a second run), and write the stub as an ESM safe rspack-merge config with a TODO(rspack) marker and an error that explains how to finish the port. --- .../codemods/additions/scripts/rspack.test.ts | 65 +++++++++---------- .../src/codemods/additions/scripts/rspack.ts | 63 +++++++++++------- 2 files changed, 69 insertions(+), 59 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index d4dedc699a..454c391559 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -407,70 +407,67 @@ describe('rspack', () => { }); }); - 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";'); + describe('root config stub', () => { + it.each(['webpack.config.ts', 'webpack.config.js', 'webpack.config.cjs', 'webpack.config.mjs'])( + 'should add a root rspack.config.ts stub when %s exists', + (rootConfig) => { + const context = createBaseContext(); + context.addFile(rootConfig, 'custom webpack config'); - const result = addRspack(context); + const result = addRspack(context); - expect(result.doesFileExist('rspack.config.ts')).toBe(true); - }); + expect(result.doesFileExist('rspack.config.ts')).toBe(true); + } + ); - it('should include throw Error in root rspack.config.ts', () => { + it('should make the stub fail the build until the config is ported', () => { const context = createBaseContext(); context.addFile('webpack.config.ts', 'custom webpack config'); const result = addRspack(context); - const rspackConfig = result.getFile('rspack.config.ts')!; + const stub = result.getFile('rspack.config.ts')!; - expect(rspackConfig).toContain('throw new Error'); - expect(rspackConfig).toContain('[add-rspack]'); + expect(stub).toContain('throw new Error('); + expect(stub).toContain('[rspack]'); + expect(stub).toContain('add rspack --agent'); + expect(stub).toContain('TODO(rspack)'); }); - it('should reference webpack-merge in migration instructions', () => { + it('should write the stub as an ESM safe rspack-merge config', () => { const context = createBaseContext(); context.addFile('webpack.config.ts', 'custom webpack config'); const result = addRspack(context); - const rspackConfig = result.getFile('rspack.config.ts')!; + const stub = result.getFile('rspack.config.ts')!; - expect(rspackConfig).toContain('webpack-merge'); + expect(stub).toContain("import { merge } from 'rspack-merge';"); + expect(stub).toContain("import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts';"); + expect(stub).not.toContain('__dirname'); + expect(stub).not.toContain('webpack-merge'); }); - it('should include migration instructions in root rspack.config.ts', () => { + it('should leave the root webpack config untouched', () => { const context = createBaseContext(); - context.addFile('webpack.config.ts', 'custom webpack config'); + const originalContent = 'import grafanaConfig from "./.config/webpack/webpack.config";'; + context.addFile('webpack.config.ts', originalContent); 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'); + expect(result.getFile('webpack.config.ts')).toBe(originalContent); }); - it('should import from .config/rspack/rspack.config in root rspack.config.ts', () => { + it('should not overwrite an existing root rspack config and should report it', () => { const context = createBaseContext(); context.addFile('webpack.config.ts', 'custom webpack config'); + context.addFile('rspack.config.ts', 'my rspack 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); + expect(result.getFile('rspack.config.ts')).toBe('my rspack config'); + expect(result.getMessage()?.body?.join('\n')).toContain('rspack.config.ts'); }); - it('should not create root rspack.config.ts when no root webpack.config.ts exists', () => { + it('should not add a stub when there is no root webpack config', () => { const context = createBaseContext(); const result = addRspack(context); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 4120b6719d..9c24ba1c94 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -68,8 +68,7 @@ export default function rspack(context: Context): Context { renderTemplateFiles(context, BUNDLER_FILES); updateCprcConfig(context); - handleCustomWebpackConfig(context); - + followUps.push(...addRootRspackConfigStub(context)); followUps.push(...updatePackageJson(context)); followUps.push(...deleteWebpackTemplateFiles(context)); reportFollowUps(context, followUps); @@ -121,18 +120,33 @@ function renderTemplateFiles(context: Context, filePaths: string[]): void { } } -function handleCustomWebpackConfig(context: Context): boolean { - const hasCustomConfig = context.doesFileExist('webpack.config.ts'); +const ROOT_CONFIG_EXTENSIONS = ['ts', 'js', 'cjs', 'mjs', 'cts', 'mts']; - if (!hasCustomConfig) { - return false; +// a root webpack config means the plugin extends the build. the stub makes the build fail loudly +// until that config is ported, so a plugin cannot ship without the build features it relies on +function addRootRspackConfigStub(context: Context): string[] { + const rootWebpackConfig = ROOT_CONFIG_EXTENSIONS.map((extension) => `webpack.config.${extension}`).find((filePath) => + context.doesFileExist(filePath) + ); + if (!rootWebpackConfig) { + return []; } - additionsDebug('Custom root webpack.config.ts detected. Creating rspack.config.ts stub with migration instructions.'); + const existingRootRspackConfig = ROOT_CONFIG_EXTENSIONS.map((extension) => `rspack.config.${extension}`).find( + (filePath) => context.doesFileExist(filePath) + ); + if (existingRootRspackConfig) { + return [ + `${existingRootRspackConfig} already exists, so it was left as is. Check that it includes everything from ${rootWebpackConfig}.`, + ]; + } - context.addFile('rspack.config.ts', ROOT_RSPACK_CONFIG_TEMPLATE); + additionsDebug(`Custom ${rootWebpackConfig} detected. Adding a rspack.config.ts stub that fails until it is ported.`); + context.addFile('rspack.config.ts', getRootRspackConfigStub(rootWebpackConfig)); - return true; + return [ + `rspack.config.ts was added and fails the build until the customisations in ${rootWebpackConfig} are ported to it.`, + ]; } interface PackageJson { @@ -270,29 +284,28 @@ function deleteWebpackTemplateFiles(context: Context): string[] { ]; } -const ROOT_RSPACK_CONFIG_TEMPLATE = `import type { Configuration } from '@rspack/core'; -import grafanaConfig from './.config/rspack/rspack.config'; +function getRootRspackConfigStub(rootWebpackConfig: string): string { + return `import type { Configuration } from '@rspack/core'; +import { merge } from 'rspack-merge'; -// 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 +import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts'; +// TODO(rspack): port the customisations in ./${rootWebpackConfig} to this file, then delete it. +// Run \`npx @grafana/create-plugin@latest add rspack --agent\` to have an AI agent do the port, +// or follow https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations +// This file is loaded as native ESM, so CommonJS globals are not available. Use import.meta.dirname, +// createRequire(import.meta.url) and .ts extensions on relative imports. 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.' + '[rspack] ${rootWebpackConfig} has not been ported to rspack.config.ts yet. ' + + 'Run \`npx @grafana/create-plugin@latest add rspack --agent\` or port it by hand, then remove this error.' ); -const config = async (env: Record): Promise => { +const config = async (env: Env): Promise => { const baseConfig = await grafanaConfig(env); - return baseConfig; + + return merge(baseConfig, {}); }; export default config; `; +} From 236c1ac590637a62cbff220451e2cb5d8c6411fd Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:29:25 +0200 Subject: [PATCH 13/25] feat: raise the node engine and unblock npm upgrades from rspack 1 Raise engines.node to >=22.23 when the plugin allows older Node versions than rspack 2 and native TypeScript config loading need. When npm has rspack 1 locked, drop the locked @rspack packages so the install can resolve rspack 2 instead of failing with ERESOLVE. Also cover the experimental rspack starting point in tests. --- .../codemods/additions/scripts/rspack.test.ts | 129 ++++++++++++++++++ .../src/codemods/additions/scripts/rspack.ts | 47 +++++++ 2 files changed, 176 insertions(+) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 454c391559..7b0b8b5fcf 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -380,6 +380,135 @@ describe('rspack', () => { }); }); + describe('experimental rspack start', () => { + function createExperimentalRspackContext(devDependencies: Record) { + const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'experimental rspack config'); + context.addFile( + '.config/.cprc.json', + JSON.stringify({ version: '7.9.0', features: { useExperimentalRspack: true } }, null, 2) + ); + context.addFile('rspack.config.ts', 'my rspack config'); + context.addFile( + 'package.json', + JSON.stringify( + { + scripts: { build: 'rspack -c ./rspack.config.ts --env production' }, + devDependencies: { 'eslint-webpack-plugin': '^5.0.0', webpack: '^5.94.0', ...devDependencies }, + }, + null, + 2 + ) + ); + return context; + } + + it('should hand the root rspack config to the user untouched and not add a stub', () => { + const context = createExperimentalRspackContext({ '@rspack/core': '^1.6.0', '@rspack/cli': '^1.6.0' }); + + const result = addRspack(context); + + expect(result.getFile('rspack.config.ts')).toBe('my rspack config'); + }); + + it('should swap eslint-webpack-plugin for eslint-rspack-plugin', () => { + const context = createExperimentalRspackContext({ '@rspack/core': '^1.6.0', '@rspack/cli': '^1.6.0' }); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['eslint-webpack-plugin']).toBeUndefined(); + expect(pkg.devDependencies['eslint-rspack-plugin']).toBeDefined(); + }); + + it('should keep rspack versions the plugin already pinned at or above the template', () => { + const context = createExperimentalRspackContext({ '@rspack/core': '2.2.6', '@rspack/cli': '2.2.6' }); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + expect(pkg.devDependencies['@rspack/core']).toBe('2.2.6'); + expect(pkg.devDependencies['@rspack/cli']).toBe('2.2.6'); + }); + }); + + describe('engines', () => { + function runWithEngines(engines?: Record) { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + if (engines) { + pkg.engines = engines; + } + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + return JSON.parse(addRspack(context).getFile('package.json')!).engines; + } + + it('should raise engines.node when it allows versions rspack 2 cannot run on', () => { + expect(runWithEngines({ node: '>=22' })).toEqual({ node: '>=22.23' }); + expect(runWithEngines({ node: '>=20', npm: '>=10' })).toEqual({ node: '>=22.23', npm: '>=10' }); + }); + + it('should keep engines.node when it already requires a newer Node', () => { + expect(runWithEngines({ node: '>=24' })).toEqual({ node: '>=24' }); + expect(runWithEngines({ node: '>=22.23.3' })).toEqual({ node: '>=22.23.3' }); + }); + + it('should not add engines when the plugin has none', () => { + expect(runWithEngines()).toBeUndefined(); + }); + }); + + describe('npm lockfile', () => { + function createLockfile(rspackCoreVersion: string) { + return JSON.stringify( + { + name: 'my-plugin', + lockfileVersion: 3, + packages: { + '': { name: 'my-plugin' }, + 'node_modules/@rspack/core': { version: rspackCoreVersion }, + 'node_modules/@rspack/cli': { version: rspackCoreVersion }, + 'node_modules/@rspack/cli/node_modules/@rspack/dev-server': { version: '1.1.5' }, + 'node_modules/react': { version: '18.3.1' }, + }, + }, + null, + 2 + ); + } + + it('should drop locked rspack 1 packages so npm can resolve rspack 2', () => { + const context = createBaseContext(); + context.addFile('package-lock.json', createLockfile('1.7.12')); + + const result = addRspack(context); + const packages = Object.keys(JSON.parse(result.getFile('package-lock.json')!).packages); + + expect(packages).toEqual(['', 'node_modules/react']); + }); + + it('should leave the lockfile alone when rspack 2 is already locked', () => { + const context = createBaseContext(); + const lockfile = createLockfile('2.2.8'); + context.addFile('package-lock.json', lockfile); + + const result = addRspack(context); + + expect(result.getFile('package-lock.json')).toBe(lockfile); + }); + + it('should not touch other package managers lockfiles', () => { + const context = createBaseContext(); + context.addFile('pnpm-lock.yaml', "lockfileVersion: '9.0'\n"); + context.addFile('yarn.lock', '# yarn lockfile v1\n'); + + const result = addRspack(context); + + expect(result.getFile('pnpm-lock.yaml')).toBe("lockfileVersion: '9.0'\n"); + expect(result.getFile('yarn.lock')).toBe('# yarn lockfile v1\n'); + }); + }); + describe('webpack cleanup', () => { it('should delete the create-plugin webpack files from .config/webpack/', () => { const context = createBaseContext(); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 9c24ba1c94..fc113c1d22 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from 'node:url'; +import { lt, major, minVersion, validRange } from 'semver'; import type { Context } from '../../context.js'; import { additionsDebug, @@ -151,6 +152,7 @@ function addRootRspackConfigStub(context: Context): string[] { interface PackageJson { scripts?: Record; + engines?: Record; [key: string]: unknown; } @@ -185,6 +187,9 @@ function updatePackageJson(context: Context): string[] { context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2)); } + raiseNodeEngine(context); + dropLockedRspackOnePackages(context); + if (unparsedScripts.length > 0) { followUps.push( 'These scripts still call webpack in a way the addition could not rewrite. Change them to call rspack with ./rspack.config.ts or ./.config/rspack/rspack.config.ts:', @@ -195,6 +200,48 @@ function updatePackageJson(context: Context): string[] { return followUps; } +// rspack 2 needs Node 22.12. 22.23 also has type stripping on by default, so the rspack CLI can +// always load TypeScript configs natively. matches the engines range create-plugin scaffolds +const MIN_NODE_VERSION = '22.23.0'; + +function raiseNodeEngine(context: Context): void { + const packageJson = readJsonFile(context, 'package.json'); + const nodeRange = packageJson.engines?.node; + if (!nodeRange || !validRange(nodeRange)) { + return; + } + + const lowestAllowedVersion = minVersion(nodeRange); + if (!lowestAllowedVersion || !lt(lowestAllowedVersion, MIN_NODE_VERSION)) { + return; + } + + const engines = { ...packageJson.engines, node: '>=22.23' }; + context.updateFile('package.json', JSON.stringify({ ...packageJson, engines }, null, 2)); +} + +// npm refuses to move @rspack/cli and @rspack/core from 1 to 2 in one install while the lockfile +// still pins the old versions (ERESOLVE). dropping the locked @rspack packages lets npm resolve +// them again from package.json. pnpm and yarn upgrade them without help +const LOCKED_RSPACK_PACKAGE_PATTERN = /(^|\/)node_modules\/@rspack\//; + +function dropLockedRspackOnePackages(context: Context): void { + if (!context.doesFileExist('package-lock.json')) { + return; + } + + const lockfile = readJsonFile<{ packages?: Record }>(context, 'package-lock.json'); + const lockedCoreVersion = lockfile.packages?.['node_modules/@rspack/core']?.version; + if (!lockfile.packages || !lockedCoreVersion || major(lockedCoreVersion) >= 2) { + return; + } + + const packages = Object.fromEntries( + Object.entries(lockfile.packages).filter(([path]) => !LOCKED_RSPACK_PACKAGE_PATTERN.test(path)) + ); + context.updateFile('package-lock.json', JSON.stringify({ ...lockfile, packages }, null, 2) + '\n'); +} + // a webpack or webpack-cli command at the start of a script, after a shell separator, after env // assignments or after npx. the lookbehind keeps the separator and prefixes out of the match const WEBPACK_COMMAND_PATTERN = /(?<=(?:^|&&|\|\||;|\|)\s*(?:\w+=\S*\s+)*(?:npx\s+)?)webpack(?:-cli)?(?=\s|$)/g; From 0238934d6602a15f78b8d216d5fc47353d637323 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:30:19 +0200 Subject: [PATCH 14/25] test: cover rspack addition idempotency for every starting point --- .../codemods/additions/scripts/rspack.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 7b0b8b5fcf..d02a9a0ff0 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -509,6 +509,42 @@ describe('rspack', () => { }); }); + describe('idempotency', () => { + const run = async (context: Context) => addRspack(context); + + it('should be idempotent for a webpack plugin', async () => { + await expect(run).toBeIdempotent(createBaseContext()); + }); + + it('should be idempotent for a webpack plugin with a root config, helpers and a lockfile', async () => { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.scripts = { build: 'pnpm run prebuild && webpack -c ./webpack.config.ts --env production' }; + pkg.engines = { node: '>=22' }; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + context.addFile('webpack.config.ts', "import CopyWebpackPlugin from 'copy-webpack-plugin';"); + context.addFile('.config/webpack/CorsWorkerPlugin.ts', 'export class CorsWorkerPlugin {}'); + context.addFile( + 'package-lock.json', + JSON.stringify({ packages: { 'node_modules/@rspack/core': { version: '1.7.12' } } }, null, 2) + ); + + await expect(run).toBeIdempotent(context); + }); + + it('should be idempotent for an experimental rspack plugin', async () => { + const context = new Context('/virtual'); + context.addFile('.config/rspack/rspack.config.ts', 'experimental rspack config'); + context.addFile('rspack.config.ts', 'my rspack config'); + context.addFile( + 'package.json', + JSON.stringify({ scripts: { build: 'rspack -c ./rspack.config.ts' }, devDependencies: {} }, null, 2) + ); + + await expect(run).toBeIdempotent(context); + }); + }); + describe('webpack cleanup', () => { it('should delete the create-plugin webpack files from .config/webpack/', () => { const context = createBaseContext(); From 21a3f33ff12a8aa8243e0d09314898bdbbb8a5fe Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:31:51 +0200 Subject: [PATCH 15/25] feat: register rspack as a hybrid addition with agent instructions The codemod half moves create-plugin owned files to rspack 2. The prompt covers the part that needs judgement: porting a root webpack config, or upgrading an experimental rspack config, to rspack 2. It lists the API mapping and known traps, and asks for unportable code to be commented out with TODO(rspack) so the build still passes. --- .../src/codemods/additions/additions.test.ts | 8 ++ .../src/codemods/additions/additions.ts | 6 + .../src/codemods/additions/prompts/rspack.md | 103 ++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 packages/create-plugin/src/codemods/additions/prompts/rspack.md diff --git a/packages/create-plugin/src/codemods/additions/additions.test.ts b/packages/create-plugin/src/codemods/additions/additions.test.ts index 19dc4efdf9..a5bebd201f 100644 --- a/packages/create-plugin/src/codemods/additions/additions.test.ts +++ b/packages/create-plugin/src/codemods/additions/additions.test.ts @@ -56,6 +56,14 @@ describe('additions json', () => { expect(defaultAdditions.length).toBeGreaterThan(0); }); + it('should register rspack as a hybrid addition', () => { + const rspack = defaultAdditions.find((addition) => addition.name === 'rspack'); + + expect(rspack).toBeDefined(); + expect(isScriptAddition(rspack!)).toBe(true); + expect(hasPromptStep(rspack!)).toBe(true); + }); + defaultAdditions.forEach((addition) => { it(`should declare a script path or a prompt for ${addition.name}`, () => { expect(isScriptAddition(addition) || hasPromptStep(addition)).toBe(true); diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index 7018dd6cee..c8477afabc 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -43,6 +43,12 @@ const additions: Addition[] = [ description: 'Externalizes the react JSX runtime to help migrate plugins to React 19', scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), }, + { + name: 'rspack', + description: 'Migrates the plugin frontend build from webpack or experimental rspack to rspack 2', + scriptPath: import.meta.resolve('./scripts/rspack.js'), + prompt: import.meta.resolve('./prompts/rspack.md'), + }, { name: 'experimental-app-sdk', description: 'Adds grafana-app-sdk CUE kind code generation to an app plugin', diff --git a/packages/create-plugin/src/codemods/additions/prompts/rspack.md b/packages/create-plugin/src/codemods/additions/prompts/rspack.md new file mode 100644 index 0000000000..9a9ca6de35 --- /dev/null +++ b/packages/create-plugin/src/codemods/additions/prompts/rspack.md @@ -0,0 +1,103 @@ +# Migrate the plugin's custom build configuration to rspack + +## Context + +This plugin is moving its frontend build to rspack 2. The `create-plugin add rspack` codemod has already done the parts that create-plugin owns: + +- Rendered the rspack base config in `.config/rspack/` and the shared helpers in `.config/bundler/`, and set `useExperimentalRspack` in `.config/.cprc.json`. +- Deleted the create-plugin files in `.config/webpack/`. It kept any file there that the plugin added and listed it in its output. +- Added the rspack packages (`@rspack/core`, `@rspack/cli`, `rspack-merge`, `eslint-rspack-plugin` and others) and removed webpack-only packages. It kept any webpack package that a root `webpack.*` or `rspack.*` file still imports and listed it. +- Rewrote `webpack` commands in `package.json` scripts to `rspack`. It listed any script it could not rewrite. +- If the plugin has a root `webpack.config.*`, added a root `rspack.config.ts` that throws an `[rspack]` error until the custom config is ported. +- Raised `engines.node` to `>=22.23` if it allowed older versions. + +What is left is the plugin's own build customisation, which needs judgement to port. The previous base config is still in git: `git show HEAD:.config/webpack/webpack.config.ts`. Compare it with `.config/rspack/rspack.config.ts` when you need to know how a base rule or plugin changed. + +## Check first + +1. Install dependencies with the plugin's package manager, then run the `build` script. Keep the output; it is evidence, not a to-do list. +2. Stop and report that nothing was needed if all of these are true: + - The build passes. + - There is no root `webpack.config.*` file. + - Root `rspack.config.ts`, if present, does not contain the `[rspack]` error. + - No script in `package.json` calls `webpack`. +3. Otherwise, work out the starting point: + - **From webpack**: there is a root `webpack.config.*`, or `rspack.config.ts` contains the `[rspack]` error. Follow all the steps below. + - **From experimental rspack**: there is a root `rspack.config.ts` without the `[rspack]` error, and there is no root `webpack.config.*`. The config is already rspack but may use rspack 1 APIs. Skip step 2 and upgrade that file in place using steps 3 to 5. + +With a custom config, the build is expected to fail at this point with the `[rspack]` error. Do not chase build errors one by one; the steps below drive the work. + +## Steps + +1. Find every file that is part of the custom build: the root `webpack.config.*`, any file it imports (for example `webpack.config.utils.ts`), and any file the codemod reported as kept in `.config/webpack/`. + +2. Replace the body of root `rspack.config.ts` with a port of the root webpack config: + - Import the base config with `import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts';`. + - Merge with `rspack-merge` (`merge`, `mergeWithRules`, `mergeWithCustomize`, `unique`). It has the same API as `webpack-merge`, typed for rspack. Await the base config before merging. + - Keep the structure, comments and behaviour of the original. Port it; do not improve it. + - Remove the `[rspack]` error and the `TODO(rspack)` comment above it only when the port is complete. + +3. Replace webpack APIs and packages with their rspack equivalents. Import from `@rspack/core` with named imports, for example `import { DefinePlugin, NormalModuleReplacementPlugin } from '@rspack/core';`. + + | webpack | rspack | + | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | + | `webpack` built-in plugins (`DefinePlugin`, `EnvironmentPlugin`, `BannerPlugin`, `ProvidePlugin`, `NormalModuleReplacementPlugin`, `ContextReplacementPlugin`) and `Compilation` | the same names from `@rspack/core` | + | `webpack-merge` | `rspack-merge` | + | `copy-webpack-plugin` | `CopyRspackPlugin` from `@rspack/core` | + | `swc-loader`, `babel-loader` | `builtin:swc-loader` | + | `fork-ts-checker-webpack-plugin` | `TsCheckerRspackPlugin` from `ts-checker-rspack-plugin` | + | `eslint-webpack-plugin` | `eslint-rspack-plugin` (it has no `failOnError`; use `severity`) | + | `webpack-livereload-plugin` | already in the base config for development builds | + | `mini-css-extract-plugin` | `CssExtractRspackPlugin` from `@rspack/core` | + | `css-minimizer-webpack-plugin` | `LightningCssMinimizerRspackPlugin` from `@rspack/core` | + | `terser-webpack-plugin` added by the plugin | `SwcJsMinimizerRspackPlugin` from `@rspack/core`, or keep terser | + | `html-webpack-plugin` | `HtmlRspackPlugin` from `@rspack/core` | + | `tsconfig-paths-webpack-plugin` | the `resolve.tsConfig` option | + | `file-loader`, `url-loader`, `raw-loader` | asset modules: `type: 'asset/resource'`, `'asset/inline'`, `'asset/source'` | + | plugins built on unplugin (`x/webpack`) | the `x/rspack` entry point, where one exists | + | imports from `.config/webpack/utils` or `.config/webpack/constants` | `.config/bundler/utils.ts` and `.config/bundler/constants.ts` | + + Check the [rspack migration guide](https://rspack.rs/guide/migration/webpack) for anything not listed here. + +4. Fix code that depends on how the base config is built. Each of these works in webpack and silently does nothing, or throws, in rspack: + - **Looking up a rule by loader name**, such as `rule.use.loader === 'swc-loader'`. The base config uses `builtin:swc-loader`, so match that. Prefer merging a rule over mutating the base one. + - **Looking up a plugin with `instanceof` or `constructor.name`** against webpack plugin classes, such as `CopyWebpackPlugin` or `LiveReloadPlugin`. Use the rspack class names, or merge instead of mutating. + - **`webpack-merge` rules keyed on old names**: `unique('plugins', ['CopyPlugin'], …)` or `mergeWithRules` matching `use.loader: 'swc-loader'`. Update the names. + - **A `filter` function on a `CopyWebpackPlugin` pattern**. `CopyRspackPlugin` has no `filter`; use `globOptions: { ignore: [...] }`. The base copy patterns come from `.config/bundler/copyFiles.ts`. + - **Removing the live reload plugin by filtering the plugins array**. The rspack base only adds it in development; filter on the rspack plugin class. + - **Native ESM loading**: the rspack CLI loads `rspack.config.ts` as native ESM. Replace `__dirname` with `import.meta.dirname`, `require.resolve(x)` with `createRequire(import.meta.url).resolve(x)`, and `require(x)` with `import`. Give relative imports explicit `.ts` extensions. `ReferenceError: __dirname is not defined` at build time means this step is not done. + - **rspack 2 defaults**: imports of exports that do not exist now fail the build (`exportsPresence: 'error'`). Fix the import in the plugin's code if it is clearly wrong; otherwise report it rather than changing the setting. + - **rspack 2 option moves**: `experiments.cache`, `experiments.incremental` and `experiments.lazyCompilation` are now top level. `experiments.css` is replaced by `module.rules[].type: 'css' | 'css/module' | 'css/auto'`. `experiments.outputModule` is `output.module`. `output.libraryTarget`, `libraryExport` and `umdNamedDefine` move under `output.library`. `SubresourceIntegrityPlugin` is no longer under `experiments`. + - **Persistent cache**: the base config has none. If the webpack config set `cache.type: 'filesystem'`, drop it unless the plugin clearly needs it. + +5. If something has no rspack equivalent, for example a monkey-patch of webpack internals, a custom `RuntimeModule` plugin, or an SWC Wasm plugin whose ABI does not match rspack's built-in SWC: + - Port what you can. + - Comment out the rest, keeping the original code in the comment, so it does not run and the build does not fail. + - Put a `TODO(rspack):` comment above it that says what the code did, why it has no direct equivalent, and a suggested approach. + - Never drop it silently. + +6. Fix any script the codemod reported as not rewritten so it calls `rspack` with `./rspack.config.ts` or `./.config/rspack/rspack.config.ts`, keeping its other commands and flags. Remove the CLI flags rspack does not support: `--progress`, `--color`, `--bail`, `--output-pathinfo`. + +7. For each file the codemod kept in `.config/webpack/`, copy what is still needed to a folder outside `.config/` (for example `build/`), port it to rspack, and update the imports. Do not delete the originals; list them so the user can delete them. + +8. Remove the webpack packages the codemod kept, once nothing imports them. Keep `webpack` itself if anything still imports it. + +9. Delete the root `webpack.config.*` and its helper files once everything in them is ported or commented with `TODO(rspack):`. + +## Verify + +1. Install dependencies, then run the `typecheck` and `build` scripts. Both must pass, with any unportable code commented out. +2. Check that `dist/` contains `module.js`, `plugin.json`, and the images and other assets the webpack build produced. +3. Run the `dev` script briefly and check that it compiles and keeps watching. +4. Fix only breakage caused by this migration. + +## Report + +End with a list of every `TODO(rspack):` comment you added (file, line, one-line reason), every file the user should delete, and any `exportsPresence` errors you left for the user. + +## Out of scope + +- Do not modify anything under `.config/`. It is managed by create-plugin and is overwritten by `create-plugin update`. +- Do not change the plugin id or type in `src/plugin.json`. +- Do not upgrade unrelated dependencies, and do not reformat files you did not change. +- Do not refactor plugin source code, unless the build fails because of the bundler change. From ef4465efba52cb2f12958157e24e7ced06d580e1 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 17:36:37 +0200 Subject: [PATCH 16/25] fix: add packages the rspack base config needs when a plugin dropped them The template scaffolds @swc/helpers for every plugin, but webpack builds never needed it and many plugins removed it. The rspack base config runs builtin:swc-loader with externalHelpers, so their builds failed to resolve @swc/helpers. Add the packages the base config loads when they are missing, and leave existing versions alone. Found by running the addition on grafana-pyroscope-app. --- .../codemods/additions/scripts/rspack.test.ts | 27 +++++++++++ .../src/codemods/additions/scripts/rspack.ts | 46 +++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index d02a9a0ff0..3df2cbae8b 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -212,6 +212,33 @@ describe('rspack', () => { } }); + it('should add packages the rspack base config needs when the plugin has dropped them', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const context = createBaseContext(); + + const result = addRspack(context); + const pkg = JSON.parse(result.getFile('package.json')!); + + // builtin:swc-loader runs with externalHelpers, so builds import @swc/helpers + expect(pkg.devDependencies['@swc/helpers']).toBe(rspackDeps['@swc/helpers']); + expect(pkg.devDependencies['imports-loader']).toBe(rspackDeps['imports-loader']); + }); + + it('should not change the versions of base config packages the plugin already has', () => { + const context = createBaseContext(); + const pkg = JSON.parse(context.getFile('package.json')!); + pkg.devDependencies['sass-loader'] = '^13.0.0'; + pkg.dependencies = { '@swc/helpers': '0.4.14' }; + context.updateFile('package.json', JSON.stringify(pkg, null, 2)); + + const result = addRspack(context); + const updated = JSON.parse(result.getFile('package.json')!); + + expect(updated.devDependencies['sass-loader']).toBe('^13.0.0'); + expect(updated.dependencies['@swc/helpers']).toBe('0.4.14'); + expect(updated.devDependencies['@swc/helpers']).toBeUndefined(); + }); + it('should upgrade rspack devDependencies that are below the template version', async () => { const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); const context = createBaseContext(); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index fc113c1d22..ae05a80816 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -151,6 +151,8 @@ function addRootRspackConfigStub(context: Context): string[] { } interface PackageJson { + dependencies?: Record; + devDependencies?: Record; scripts?: Record; engines?: Record; [key: string]: unknown; @@ -163,14 +165,21 @@ function updatePackageJson(context: Context): string[] { } const followUps: string[] = []; - const { rspackOnlyDevDependencies, webpackOnlyDevDependencies } = getBundlerDevDependencies(); + const { rspackDevDependencies, rspackOnlyDevDependencies, webpackOnlyDevDependencies } = getBundlerDevDependencies(); const userBuildFileSources = readUserBuildFileSources(context); const stillUsedDevDependencies = webpackOnlyDevDependencies.filter((name) => userBuildFileSources.some((source) => referencesPackage(source, name)) ); const unusedDevDependencies = webpackOnlyDevDependencies.filter((name) => !stillUsedDevDependencies.includes(name)); - addDependenciesToPackageJson(context, {}, rspackOnlyDevDependencies); + addDependenciesToPackageJson( + context, + {}, + { + ...getMissingBaseConfigDevDependencies(context, rspackDevDependencies), + ...rspackOnlyDevDependencies, + } + ); removeDependenciesFromPackageJson(context, [], unusedDevDependencies); if (stillUsedDevDependencies.length > 0) { @@ -289,6 +298,37 @@ function referencesPackage(source: string, packageName: string): boolean { return new RegExp(`['"\`]${escapedName}(/[^'"\`]*)?['"\`]`).test(source); } +// packages the rendered .config/rspack and .config/bundler files load at build time that both bundlers +// scaffold, so they are not in the rspack-only set. older plugins may have removed some of them. +// @swc/helpers is needed because builtin:swc-loader runs with externalHelpers, which webpack did not +const BASE_CONFIG_PACKAGES = [ + '@swc/helpers', + 'css-loader', + 'glob', + 'imports-loader', + 'replace-in-file-webpack-plugin', + 'sass', + 'sass-loader', + 'style-loader', + 'terser-webpack-plugin', +]; + +// only adds what is missing. a plugin that already has one of these keeps its version +function getMissingBaseConfigDevDependencies( + context: Context, + rspackDevDependencies: Record +): Record { + const packageJson = readJsonFile(context, 'package.json'); + const installed = { ...packageJson.dependencies, ...packageJson.devDependencies }; + + return Object.fromEntries( + BASE_CONFIG_PACKAGES.filter((name) => !(name in installed) && name in rspackDevDependencies).map((name) => [ + name, + rspackDevDependencies[name], + ]) + ); +} + // The rspack and webpack renders of the package.json template are the source of truth for which // dev dependencies each bundler needs, so the addition never drifts from what `generate` scaffolds function getBundlerDevDependencies() { @@ -303,7 +343,7 @@ function getBundlerDevDependencies() { (name) => !(name in rspackDevDependencies) ); - return { rspackOnlyDevDependencies, webpackOnlyDevDependencies }; + return { rspackDevDependencies, rspackOnlyDevDependencies, webpackOnlyDevDependencies }; } function renderDevDependencies(templatePath: string, templateData: Record): Record { From 4906f0dda30b728b9b755c33550b3c14cef33a20 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 1 Oct 2026 08:01:13 +0200 Subject: [PATCH 17/25] docs: document the rspack addition --- .../how-to-guides/add-features-to-a-plugin.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md b/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md index 73d9e44de4..4a967c5bb8 100644 --- a/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md +++ b/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md @@ -105,6 +105,29 @@ Instructions ask the agent to mark anything it couldn't apply with a `TODO( Date: Tue, 29 Sep 2026 18:04:38 +0200 Subject: [PATCH 18/25] refactor: list rspack addition packages explicitly, keep template versions Deriving the packages from the difference between the rspack and webpack renders of the package.json template hid what the addition installs and would do nothing once the experimental flag is removed. Name the packages in three explicit lists and take only their versions from the template, so they stay in step with the rendered .config files. Render the template with fixed data instead of the plugin's, and add tests that fail when the template and the lists drift apart. --- .../codemods/additions/scripts/rspack.test.ts | 43 ++++++- .../src/codemods/additions/scripts/rspack.ts | 113 +++++++++++------- 2 files changed, 109 insertions(+), 47 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 3df2cbae8b..2a6436d0b9 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -1,5 +1,10 @@ import { Context } from '../../context.js'; -import addRspack from './rspack.js'; +import addRspack, { + BASE_CONFIG_DEV_DEPENDENCIES, + getTemplateDevDependencyVersions, + RSPACK_DEV_DEPENDENCIES, + WEBPACK_ONLY_DEV_DEPENDENCIES, +} from './rspack.js'; vi.mock(import('../../../utils/utils.plugin.js'), async (importOriginal) => { const originalModule = await importOriginal(); @@ -25,12 +30,12 @@ vi.mock(import('../../../utils/utils.config.js'), async (importOriginal) => { vi.mock(import('../../utils.js'), async (importOriginal) => { const originalModule = await importOriginal(); - // Only render externals.ts and _package.json from the real templates since we assert on their content. + // Only render externals.ts from the real template since we assert on its content. // All other templates just need a non-empty stub. return { ...originalModule, renderTemplate: (path: string, includeWarning?: boolean, templateDataOverrides?: Record) => { - if (path.includes('.config/bundler/externals.ts') || path.endsWith('_package.json')) { + if (path.includes('.config/bundler/externals.ts')) { return originalModule.renderTemplate(path, includeWarning, templateDataOverrides); } return '// rendered template stub'; @@ -86,6 +91,38 @@ function createBaseContext(): Context { } describe('rspack', () => { + // guards the explicit package lists against the template: fails when the rspack template gains, + // loses or renames a package the addition should manage. version bumps do not fail it + describe('package lists', () => { + it('should find a template version for every package the addition installs', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + + for (const name of [...RSPACK_DEV_DEPENDENCIES, ...BASE_CONFIG_DEV_DEPENDENCIES]) { + expect(rspackDeps[name], name).toBeDefined(); + } + }); + + it('should list every package only the rspack template has', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const webpackDeps = await getTemplateDevDependencies(WEBPACK_TEMPLATE_DATA); + const rspackOnly = Object.keys(rspackDeps).filter((name) => !(name in webpackDeps)); + + expect(RSPACK_DEV_DEPENDENCIES).toEqual(expect.arrayContaining(rspackOnly)); + }); + + it('should list every package only the webpack template has', async () => { + const rspackDeps = await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA); + const webpackDeps = await getTemplateDevDependencies(WEBPACK_TEMPLATE_DATA); + const webpackOnly = Object.keys(webpackDeps).filter((name) => !(name in rspackDeps)); + + expect(WEBPACK_ONLY_DEV_DEPENDENCIES).toEqual(expect.arrayContaining(webpackOnly)); + }); + + it('should read the same versions as a render with the plugin template data', async () => { + expect(getTemplateDevDependencyVersions()).toEqual(await getTemplateDevDependencies(RSPACK_TEMPLATE_DATA)); + }); + }); + describe('start states', () => { it('should make no changes and explain why when there is no create-plugin bundler setup', () => { const context = new Context('/virtual'); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index ae05a80816..e260c12422 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -1,6 +1,8 @@ +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { lt, major, minVersion, validRange } from 'semver'; import type { Context } from '../../context.js'; +import { renderHandlebarsTemplate } from '../../../utils/utils.handlebars.js'; import { additionsDebug, addDependenciesToPackageJson, @@ -14,11 +16,6 @@ const RSPACK_TEMPLATE_DATA_OVERRIDES = { frontendBundler: 'rspack', }; -const WEBPACK_TEMPLATE_DATA_OVERRIDES = { - useExperimentalRspack: false, - frontendBundler: 'webpack', -}; - const RSPACK_CONFIG_FILES = [ '.config/rspack/rspack.config.ts', '.config/rspack/BuildModeRspackPlugin.ts', @@ -165,19 +162,21 @@ function updatePackageJson(context: Context): string[] { } const followUps: string[] = []; - const { rspackDevDependencies, rspackOnlyDevDependencies, webpackOnlyDevDependencies } = getBundlerDevDependencies(); + const templateVersions = getTemplateDevDependencyVersions(); const userBuildFileSources = readUserBuildFileSources(context); - const stillUsedDevDependencies = webpackOnlyDevDependencies.filter((name) => + const stillUsedDevDependencies = WEBPACK_ONLY_DEV_DEPENDENCIES.filter((name) => userBuildFileSources.some((source) => referencesPackage(source, name)) ); - const unusedDevDependencies = webpackOnlyDevDependencies.filter((name) => !stillUsedDevDependencies.includes(name)); + const unusedDevDependencies = WEBPACK_ONLY_DEV_DEPENDENCIES.filter( + (name) => !stillUsedDevDependencies.includes(name) + ); addDependenciesToPackageJson( context, {}, { - ...getMissingBaseConfigDevDependencies(context, rspackDevDependencies), - ...rspackOnlyDevDependencies, + ...pickVersions(getMissingPackages(context, BASE_CONFIG_DEV_DEPENDENCIES), templateVersions), + ...pickVersions(RSPACK_DEV_DEPENDENCIES, templateVersions), } ); removeDependenciesFromPackageJson(context, [], unusedDevDependencies); @@ -298,10 +297,34 @@ function referencesPackage(source: string, packageName: string): boolean { return new RegExp(`['"\`]${escapedName}(/[^'"\`]*)?['"\`]`).test(source); } -// packages the rendered .config/rspack and .config/bundler files load at build time that both bundlers -// scaffold, so they are not in the rspack-only set. older plugins may have removed some of them. +// packages the rspack base config and its helpers need that webpack plugins do not +export const RSPACK_DEV_DEPENDENCIES = [ + '@rspack/cli', + '@rspack/core', + '@types/ws', + 'eslint-rspack-plugin', + 'rspack-merge', + 'rspack-plugin-virtual-module', + 'ts-checker-rspack-plugin', + 'ws', +]; + +// packages only the webpack base config uses. removed unless a root build file still imports them +export const WEBPACK_ONLY_DEV_DEPENDENCIES = [ + 'copy-webpack-plugin', + 'eslint-webpack-plugin', + 'fork-ts-checker-webpack-plugin', + 'swc-loader', + 'webpack-cli', + 'webpack-livereload-plugin', + 'webpack-subresource-integrity', + 'webpack-virtual-modules', +]; + +// packages the rendered .config/rspack and .config/bundler files load at build time that every +// scaffold gets, so older plugins may have removed some of them. added only when missing. // @swc/helpers is needed because builtin:swc-loader runs with externalHelpers, which webpack did not -const BASE_CONFIG_PACKAGES = [ +export const BASE_CONFIG_DEV_DEPENDENCIES = [ '@swc/helpers', 'css-loader', 'glob', @@ -313,44 +336,46 @@ const BASE_CONFIG_PACKAGES = [ 'terser-webpack-plugin', ]; -// only adds what is missing. a plugin that already has one of these keeps its version -function getMissingBaseConfigDevDependencies( - context: Context, - rspackDevDependencies: Record -): Record { - const packageJson = readJsonFile(context, 'package.json'); - const installed = { ...packageJson.dependencies, ...packageJson.devDependencies }; +// the lists above say which packages the addition manages. their versions come from the package.json +// template, so they always match the .config files rendered from that same template. the template is +// rendered with fixed data, since only devDependencies are read and they do not depend on the plugin +const PACKAGE_JSON_TEMPLATE_DATA = { + ...RSPACK_TEMPLATE_DATA_OVERRIDES, + pluginName: 'plugin', + orgName: 'org', + pluginType: 'panel', + packageManagerName: 'npm', + packageManagerVersion: '10.0.0', + scenesVersion: '*', + isAppType: false, + isNPM: true, +}; - return Object.fromEntries( - BASE_CONFIG_PACKAGES.filter((name) => !(name in installed) && name in rspackDevDependencies).map((name) => [ - name, - rspackDevDependencies[name], - ]) +export function getTemplateDevDependencyVersions(): Record { + const template = readFileSync(resolveTemplatePath('_package.json'), 'utf-8'); + const rendered: { devDependencies?: Record } = JSON.parse( + renderHandlebarsTemplate(template, PACKAGE_JSON_TEMPLATE_DATA) ); + return rendered.devDependencies ?? {}; } -// The rspack and webpack renders of the package.json template are the source of truth for which -// dev dependencies each bundler needs, so the addition never drifts from what `generate` scaffolds -function getBundlerDevDependencies() { - const templatePath = resolveTemplatePath('_package.json'); - const rspackDevDependencies = renderDevDependencies(templatePath, RSPACK_TEMPLATE_DATA_OVERRIDES); - const webpackDevDependencies = renderDevDependencies(templatePath, WEBPACK_TEMPLATE_DATA_OVERRIDES); - - const rspackOnlyDevDependencies = Object.fromEntries( - Object.entries(rspackDevDependencies).filter(([name]) => !(name in webpackDevDependencies)) - ); - const webpackOnlyDevDependencies = Object.keys(webpackDevDependencies).filter( - (name) => !(name in rspackDevDependencies) +function pickVersions(names: string[], templateVersions: Record): Record { + return Object.fromEntries( + names.map((name) => { + const version = templateVersions[name]; + if (!version) { + throw new Error(`The package.json template has no version for ${name}, which the rspack addition installs.`); + } + return [name, version]; + }) ); - - return { rspackDevDependencies, rspackOnlyDevDependencies, webpackOnlyDevDependencies }; } -function renderDevDependencies(templatePath: string, templateData: Record): Record { - const rendered: { devDependencies?: Record } = JSON.parse( - renderTemplate(templatePath, false, templateData) - ); - return rendered.devDependencies ?? {}; +// a plugin that already has one of these keeps its version +function getMissingPackages(context: Context, names: string[]): string[] { + const packageJson = readJsonFile(context, 'package.json'); + const installed = { ...packageJson.dependencies, ...packageJson.devDependencies }; + return names.filter((name) => !(name in installed)); } function deleteWebpackTemplateFiles(context: Context): string[] { From 164a7312cd0a63f2310de6d34568e6f3d35b755b Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 29 Sep 2026 18:30:14 +0200 Subject: [PATCH 19/25] feat: minify rspack builds with SwcJsMinimizerRspackPlugin Replace terser-webpack-plugin with rspack's built-in SWC minimizer, so rspack plugins no longer need terser or its webpack peer for minifying. - Add the create-plugin banner after minification, so it stays the first two lines of module.js in the same format. Pipelines read it. - Merge the per-chunk license files SWC extracts into a single LICENSE.txt with LicenseRspackPlugin, keeping the fix for #2740. - Drop console.log and console.info with pure_funcs, which matches the previous drop_console list. The rspack addition installs the new plugin file and treats terser-webpack-plugin as webpack-only. --- .../codemods/additions/scripts/rspack.test.ts | 3 ++ .../src/codemods/additions/scripts/rspack.ts | 3 +- .../.config/rspack/LicenseRspackPlugin.ts | 44 +++++++++++++++++++ .../common/.config/rspack/rspack.config.ts | 41 ++++++++++------- .../templates/common/_package.json | 4 +- 5 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 2a6436d0b9..4105095680 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -192,6 +192,7 @@ describe('rspack', () => { 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); + expect(result.doesFileExist('.config/rspack/LicenseRspackPlugin.ts')).toBe(true); }); }); @@ -297,6 +298,7 @@ describe('rspack', () => { const context = createBaseContext(); const pkg = JSON.parse(context.getFile('package.json')!); pkg.devDependencies['eslint-webpack-plugin'] = '^5.0.0'; + pkg.devDependencies['terser-webpack-plugin'] = '^5.3.0'; context.updateFile('package.json', JSON.stringify(pkg, null, 2)); const result = addRspack(context); @@ -306,6 +308,7 @@ describe('rspack', () => { expect(updated.devDependencies['eslint-webpack-plugin']).toBeUndefined(); expect(updated.devDependencies['fork-ts-checker-webpack-plugin']).toBeUndefined(); expect(updated.devDependencies['swc-loader']).toBeUndefined(); + expect(updated.devDependencies['terser-webpack-plugin']).toBeUndefined(); expect(updated.devDependencies['webpack-cli']).toBeUndefined(); expect(updated.devDependencies['webpack-livereload-plugin']).toBeUndefined(); expect(updated.devDependencies['webpack-subresource-integrity']).toBeUndefined(); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index e260c12422..5840f0204b 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -20,6 +20,7 @@ const RSPACK_CONFIG_FILES = [ '.config/rspack/rspack.config.ts', '.config/rspack/BuildModeRspackPlugin.ts', '.config/rspack/liveReloadPlugin.ts', + '.config/rspack/LicenseRspackPlugin.ts', ]; const BUNDLER_FILES = [ @@ -315,6 +316,7 @@ export const WEBPACK_ONLY_DEV_DEPENDENCIES = [ 'eslint-webpack-plugin', 'fork-ts-checker-webpack-plugin', 'swc-loader', + 'terser-webpack-plugin', 'webpack-cli', 'webpack-livereload-plugin', 'webpack-subresource-integrity', @@ -333,7 +335,6 @@ export const BASE_CONFIG_DEV_DEPENDENCIES = [ 'sass', 'sass-loader', 'style-loader', - 'terser-webpack-plugin', ]; // the lists above say which packages the addition manages. their versions come from the package.json diff --git a/packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts b/packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts new file mode 100644 index 0000000000..9f1e7a9310 --- /dev/null +++ b/packages/create-plugin/templates/common/.config/rspack/LicenseRspackPlugin.ts @@ -0,0 +1,44 @@ +import { Compilation, type Compiler } from '@rspack/core'; + +const PLUGIN_NAME = 'LicenseRspackPlugin'; +const CHUNK_LICENSE_PATTERN = /\.LICENSE\.txt$/; + +// SwcJsMinimizerRspackPlugin extracts license comments into one file per chunk. Plugins with many +// chunks, for example those bundling monaco-editor, end up with dozens of identical files in dist. +// This merges them into a single LICENSE.txt, keeping each distinct comment once. +export class LicenseRspackPlugin { + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + // after the minimizer (PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE) has extracted the comments + stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE + 1, + }, + (assets) => { + const chunkLicenseFiles = Object.keys(assets) + .filter((assetName) => CHUNK_LICENSE_PATTERN.test(assetName)) + .sort(); + + if (chunkLicenseFiles.length === 0) { + return; + } + + const comments = new Set(); + for (const assetName of chunkLicenseFiles) { + const content = assets[assetName].source().toString(); + content + .split(/\n{2,}/) + .map((comment) => comment.trim()) + .filter(Boolean) + .forEach((comment) => comments.add(comment)); + compilation.deleteAsset(assetName); + } + + const { RawSource } = compiler.rspack.sources; + compilation.emitAsset('LICENSE.txt', new RawSource([...comments].join('\n\n') + '\n')); + } + ); + }); + } +} diff --git a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts index 5639291fc1..b389727bc8 100644 --- a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -5,16 +5,23 @@ * https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-webpack-config */ -import { BannerPlugin, CopyRspackPlugin, SubresourceIntegrityPlugin, type Configuration } from '@rspack/core'; +import { + BannerPlugin, + Compilation, + CopyRspackPlugin, + SubresourceIntegrityPlugin, + SwcJsMinimizerRspackPlugin, + type Configuration, +} from '@rspack/core'; import ESLintPlugin from 'eslint-rspack-plugin'; import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin'; import path from 'path'; import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; -import TerserPlugin from 'terser-webpack-plugin'; import { RspackVirtualModulePlugin } from 'rspack-plugin-virtual-module'; import RspackLiveReloadPlugin from './liveReloadPlugin.ts'; import { BuildModeRspackPlugin } from './BuildModeRspackPlugin.ts'; +import { LicenseRspackPlugin } from './LicenseRspackPlugin.ts'; import { DIST_DIR, SOURCE_DIR } from '../bundler/constants.ts'; import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, isWSL } from '../bundler/utils.ts'; import { externals } from '../bundler/externals.ts'; @@ -122,19 +129,12 @@ const config = async (env: Env): Promise => { optimization: { minimize: Boolean(env.production), minimizer: [ - new TerserPlugin({ - // Emit a single LICENSE.txt file for all comments. - extractComments: { - banner: false, - filename: 'LICENSE.txt', - }, - terserOptions: { - format: { - comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), - }, - compress: { - drop_console: ['log', 'info'], - }, + new SwcJsMinimizerRspackPlugin({ + // Extract license comments (LicenseRspackPlugin merges them into one LICENSE.txt) and strip the rest. + extractComments: { banner: false }, + minimizerOptions: { + format: { comments: false }, + compress: { pure_funcs: ['console.log', 'console.info'] }, }, }), ], @@ -157,13 +157,20 @@ const config = async (env: Env): Promise => { plugins: [ new BuildModeRspackPlugin(), + new LicenseRspackPlugin(), virtualPublicPath, // Insert create plugin version information into the bundle new BannerPlugin({ - banner: `/* [create-plugin] version: ${cpVersion} */ - /* [create-plugin] plugin: ${pluginId}@${pluginVersion} */`, + // Pipelines read these lines from the top of module.js, so keep their format exactly. + banner: [ + `/* [create-plugin] version: ${cpVersion} */`, + `/* [create-plugin] plugin: ${pluginId}@${pluginVersion} */`, + ].join('\n'), raw: true, entryOnly: true, + // Added straight after minification so the minimizer cannot strip or move the banner, + // and before source maps and hashes are generated so they include it. + stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE + 1, }), new CopyRspackPlugin({ patterns: copyFilePatterns, diff --git a/packages/create-plugin/templates/common/_package.json b/packages/create-plugin/templates/common/_package.json index eae22de6e2..3c0f1b44f0 100644 --- a/packages/create-plugin/templates/common/_package.json +++ b/packages/create-plugin/templates/common/_package.json @@ -59,8 +59,8 @@ "sass-loader": "^16.0.0", "semver": "^7.7.0", "style-loader": "^4.0.0",{{#unless useExperimentalRspack}} - "swc-loader": "^0.2.0",{{/unless}} - "terser-webpack-plugin": "^5.3.0", + "swc-loader": "^0.2.0", + "terser-webpack-plugin": "^5.3.0",{{/unless}} "ts-node": "^10.9.0",{{#if useExperimentalRspack}} "ts-checker-rspack-plugin": "^1.4.0",{{/if}} "typescript": "5.9.2", From 3df21f54fd897eacc6d91379bdb3d9cbe29449e1 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Wed, 30 Sep 2026 11:56:34 +0200 Subject: [PATCH 20/25] feat: tell the rspack agent not to port duplicate transpiler rules A root config that adds esbuild-loader, babel-loader or ts-loader for the plugin's own code stacks with the base swc rule, so every file is transpiled twice and the build still passes. grafana-asserts-app has done this since its webpack config was created. Ask the agent to fold any extra settings into builtin:swc-loader, drop the duplicate rule, and report it. --- .../src/codemods/additions/prompts/rspack.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/prompts/rspack.md b/packages/create-plugin/src/codemods/additions/prompts/rspack.md index 9a9ca6de35..bc89f9ae12 100644 --- a/packages/create-plugin/src/codemods/additions/prompts/rspack.md +++ b/packages/create-plugin/src/codemods/additions/prompts/rspack.md @@ -23,7 +23,7 @@ What is left is the plugin's own build customisation, which needs judgement to p - No script in `package.json` calls `webpack`. 3. Otherwise, work out the starting point: - **From webpack**: there is a root `webpack.config.*`, or `rspack.config.ts` contains the `[rspack]` error. Follow all the steps below. - - **From experimental rspack**: there is a root `rspack.config.ts` without the `[rspack]` error, and there is no root `webpack.config.*`. The config is already rspack but may use rspack 1 APIs. Skip step 2 and upgrade that file in place using steps 3 to 5. + - **From experimental rspack**: there is a root `rspack.config.ts` without the `[rspack]` error, and there is no root `webpack.config.*`. The config is already rspack but may use rspack 1 APIs. Skip step 2 and upgrade that file in place using steps 3 to 6. With a custom config, the build is expected to fail at this point with the `[rspack]` error. Do not chase build errors one by one; the steps below drive the work. @@ -70,19 +70,26 @@ With a custom config, the build is expected to fail at this point with the `[rsp - **rspack 2 option moves**: `experiments.cache`, `experiments.incremental` and `experiments.lazyCompilation` are now top level. `experiments.css` is replaced by `module.rules[].type: 'css' | 'css/module' | 'css/auto'`. `experiments.outputModule` is `output.module`. `output.libraryTarget`, `libraryExport` and `umdNamedDefine` move under `output.library`. `SubresourceIntegrityPlugin` is no longer under `experiments`. - **Persistent cache**: the base config has none. If the webpack config set `cache.type: 'filesystem'`, drop it unless the plugin clearly needs it. -5. If something has no rspack equivalent, for example a monkey-patch of webpack internals, a custom `RuntimeModule` plugin, or an SWC Wasm plugin whose ABI does not match rspack's built-in SWC: +5. Check whether the config adds a second transpiler for the plugin's own code. A rule that runs `esbuild-loader`, `babel-loader` or `ts-loader` on `.ts`, `.tsx`, `.js` or `.jsx` files outside `node_modules` overlaps with the base `builtin:swc-loader` rule. Every matching rule applies, so each file is transpiled twice, and the build still passes, so nothing flags it. + - Do not port the extra rule as it is. + - If the base `builtin:swc-loader` settings cover what the rule did, leave the rule out of `rspack.config.ts` and remove its loader package once nothing else uses it. + - If the rule does something the base config does not, for example a different JSX runtime or decorator support, move that setting into the `builtin:swc-loader` rule (see step 4) and leave the extra rule out. + - If you cannot tell, keep the rule with a `TODO(rspack):` comment above it explaining that it duplicates the base transpiler. + - Either way, name the rule in your report. + +6. If something has no rspack equivalent, for example a monkey-patch of webpack internals, a custom `RuntimeModule` plugin, or an SWC Wasm plugin whose ABI does not match rspack's built-in SWC: - Port what you can. - Comment out the rest, keeping the original code in the comment, so it does not run and the build does not fail. - Put a `TODO(rspack):` comment above it that says what the code did, why it has no direct equivalent, and a suggested approach. - Never drop it silently. -6. Fix any script the codemod reported as not rewritten so it calls `rspack` with `./rspack.config.ts` or `./.config/rspack/rspack.config.ts`, keeping its other commands and flags. Remove the CLI flags rspack does not support: `--progress`, `--color`, `--bail`, `--output-pathinfo`. +7. Fix any script the codemod reported as not rewritten so it calls `rspack` with `./rspack.config.ts` or `./.config/rspack/rspack.config.ts`, keeping its other commands and flags. Remove the CLI flags rspack does not support: `--progress`, `--color`, `--bail`, `--output-pathinfo`. -7. For each file the codemod kept in `.config/webpack/`, copy what is still needed to a folder outside `.config/` (for example `build/`), port it to rspack, and update the imports. Do not delete the originals; list them so the user can delete them. +8. For each file the codemod kept in `.config/webpack/`, copy what is still needed to a folder outside `.config/` (for example `build/`), port it to rspack, and update the imports. Do not delete the originals; list them so the user can delete them. -8. Remove the webpack packages the codemod kept, once nothing imports them. Keep `webpack` itself if anything still imports it. +9. Remove the webpack packages the codemod kept, once nothing imports them. Keep `webpack` itself if anything still imports it. -9. Delete the root `webpack.config.*` and its helper files once everything in them is ported or commented with `TODO(rspack):`. +10. Delete the root `webpack.config.*` and its helper files once everything in them is ported or commented with `TODO(rspack):`. ## Verify @@ -93,7 +100,7 @@ With a custom config, the build is expected to fail at this point with the `[rsp ## Report -End with a list of every `TODO(rspack):` comment you added (file, line, one-line reason), every file the user should delete, and any `exportsPresence` errors you left for the user. +End with a list of every `TODO(rspack):` comment you added (file, line, one-line reason), every file the user should delete, any duplicate transpiler rule you removed or kept (step 5), and any `exportsPresence` errors you left for the user. ## Out of scope From d34705150185983307b3a9d1d1e0c09f6a934082 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Wed, 30 Sep 2026 13:26:16 +0200 Subject: [PATCH 21/25] feat: have the rspack agent import images the base config stopped copying The rspack addition re-renders .config, so existing plugins pick up the narrower copy patterns from #2369 for the first time. Code that builds runtime URLs to files under img/ then 404s while the build still passes. Ask the agent to import those files so rspack hashes them and resolves the public path, to copy only the files that cannot be imported, and to check for this even when the plugin has no custom config. --- .../src/codemods/additions/prompts/rspack.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/prompts/rspack.md b/packages/create-plugin/src/codemods/additions/prompts/rspack.md index bc89f9ae12..fe76fd8718 100644 --- a/packages/create-plugin/src/codemods/additions/prompts/rspack.md +++ b/packages/create-plugin/src/codemods/additions/prompts/rspack.md @@ -21,9 +21,11 @@ What is left is the plugin's own build customisation, which needs judgement to p - There is no root `webpack.config.*` file. - Root `rspack.config.ts`, if present, does not contain the `[rspack]` error. - No script in `package.json` calls `webpack`. + - No plugin code builds a runtime URL to a file the new base config no longer copies into `dist` (see step 6). 3. Otherwise, work out the starting point: - **From webpack**: there is a root `webpack.config.*`, or `rspack.config.ts` contains the `[rspack]` error. Follow all the steps below. - - **From experimental rspack**: there is a root `rspack.config.ts` without the `[rspack]` error, and there is no root `webpack.config.*`. The config is already rspack but may use rspack 1 APIs. Skip step 2 and upgrade that file in place using steps 3 to 6. + - **From experimental rspack**: there is a root `rspack.config.ts` without the `[rspack]` error, and there is no root `webpack.config.*`. The config is already rspack but may use rspack 1 APIs. Skip step 2 and upgrade that file in place using steps 3 to 7. + - **No custom config**: there is neither a root `webpack.config.*` nor a root `rspack.config.ts`. Only step 6 applies. With a custom config, the build is expected to fail at this point with the `[rspack]` error. Do not chase build errors one by one; the steps below drive the work. @@ -77,34 +79,40 @@ With a custom config, the build is expected to fail at this point with the `[rsp - If you cannot tell, keep the rule with a `TODO(rspack):` comment above it explaining that it duplicates the base transpiler. - Either way, name the rule in your report. -6. If something has no rspack equivalent, for example a monkey-patch of webpack internals, a custom `RuntimeModule` plugin, or an SWC Wasm plugin whose ABI does not match rspack's built-in SWC: +6. Check for files the old base config copied into `dist` that the new one does not. The old base copied every `img/**`, `**/*.svg`, `**/*.png`, `**/*.html`, `libs/**` and `static/**` file; compare `git show HEAD:.config/webpack/webpack.config.ts` with `.config/bundler/copyFiles.ts`. The new base copies only `plugin.json` and other JSON files, the README, CHANGELOG and LICENSE, `query_help.md`, and the logos and screenshots listed in `plugin.json`. Code that builds a URL to one of the other files at runtime, for example `public/plugins//img/…` or `` `${pluginPublicPath}/img/how-it-works.svg` ``, now gets a 404. The build still passes, so nothing flags it. + - Where the path is a literal, import the file and use the imported URL, for example `import howItWorks from '../img/how-it-works.svg';` and `src={howItWorks}`. rspack then hashes the file and resolves its public path, including when the plugin is served from a CDN. + - Check what an import returns before converting. If the config sends `.svg` imports to `@svgr/webpack` or another loader, the import is a React component, not a URL. Use that loader's URL form if it has one, such as a `?url` resource query, or copy the file instead. + - The base asset rules only cover `png`, `jpg`, `jpeg`, `gif`, `svg` and font files. For other types, such as `webp`, add an `asset/resource` rule in `rspack.config.ts`. + - Copy the file instead of importing it when the path is built at runtime, for example `` `${base}/img/${name}.svg` ``, or when something outside the bundle refers to it, such as the README, documentation or another plugin. Add a `CopyRspackPlugin` to `rspack.config.ts` for just those files, with a comment saying why they are copied. Do not copy whole folders to be safe: every copied file ends up in the plugin archive. + +7. If something has no rspack equivalent, for example a monkey-patch of webpack internals, a custom `RuntimeModule` plugin, or an SWC Wasm plugin whose ABI does not match rspack's built-in SWC: - Port what you can. - Comment out the rest, keeping the original code in the comment, so it does not run and the build does not fail. - Put a `TODO(rspack):` comment above it that says what the code did, why it has no direct equivalent, and a suggested approach. - Never drop it silently. -7. Fix any script the codemod reported as not rewritten so it calls `rspack` with `./rspack.config.ts` or `./.config/rspack/rspack.config.ts`, keeping its other commands and flags. Remove the CLI flags rspack does not support: `--progress`, `--color`, `--bail`, `--output-pathinfo`. +8. Fix any script the codemod reported as not rewritten so it calls `rspack` with `./rspack.config.ts` or `./.config/rspack/rspack.config.ts`, keeping its other commands and flags. Remove the CLI flags rspack does not support: `--progress`, `--color`, `--bail`, `--output-pathinfo`. -8. For each file the codemod kept in `.config/webpack/`, copy what is still needed to a folder outside `.config/` (for example `build/`), port it to rspack, and update the imports. Do not delete the originals; list them so the user can delete them. +9. For each file the codemod kept in `.config/webpack/`, copy what is still needed to a folder outside `.config/` (for example `build/`), port it to rspack, and update the imports. Do not delete the originals; list them so the user can delete them. -9. Remove the webpack packages the codemod kept, once nothing imports them. Keep `webpack` itself if anything still imports it. +10. Remove the webpack packages the codemod kept, once nothing imports them. Keep `webpack` itself if anything still imports it. -10. Delete the root `webpack.config.*` and its helper files once everything in them is ported or commented with `TODO(rspack):`. +11. Delete the root `webpack.config.*` and its helper files once everything in them is ported or commented with `TODO(rspack):`. ## Verify 1. Install dependencies, then run the `typecheck` and `build` scripts. Both must pass, with any unportable code commented out. -2. Check that `dist/` contains `module.js`, `plugin.json`, and the images and other assets the webpack build produced. +2. Check that `dist/` contains `module.js`, `plugin.json`, and every file that plugin code or `plugin.json` refers to by path at runtime. Imported images are hashed, so their names change. 3. Run the `dev` script briefly and check that it compiles and keeps watching. 4. Fix only breakage caused by this migration. ## Report -End with a list of every `TODO(rspack):` comment you added (file, line, one-line reason), every file the user should delete, any duplicate transpiler rule you removed or kept (step 5), and any `exportsPresence` errors you left for the user. +End with a list of every `TODO(rspack):` comment you added (file, line, one-line reason), every file the user should delete, any duplicate transpiler rule you removed or kept (step 5), every image reference you changed to an import or kept as a copy (step 6), and any `exportsPresence` errors you left for the user. ## Out of scope - Do not modify anything under `.config/`. It is managed by create-plugin and is overwritten by `create-plugin update`. - Do not change the plugin id or type in `src/plugin.json`. - Do not upgrade unrelated dependencies, and do not reformat files you did not change. -- Do not refactor plugin source code, unless the build fails because of the bundler change. +- Do not refactor plugin source code, unless the build fails because of the bundler change or step 6 needs an image import. From 63e77145133a6a8bd1bc29d61126ce36a3550bc0 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 1 Oct 2026 08:52:53 +0200 Subject: [PATCH 22/25] fix: keep the rspack watch running when the live reload port is taken The live reload server had no error listener, so an EADDRINUSE on port 35729 crashed the whole rspack watch after its first build, for example when another plugin's dev build or an orphaned watcher held the port. Log one warning instead and retry on later rebuilds. ws re-emits the http server's errors, so both servers need the listener. Also connect the client with ws:// rather than http://, and rename the plugin to LiveReloadRspackPlugin.ts to match the other rspack plugins. The addition removes the old liveReloadPlugin.ts from experimental setups. --- .../codemods/additions/scripts/rspack.test.ts | 7 +++++-- .../src/codemods/additions/scripts/rspack.ts | 14 ++++++++++++- ...oadPlugin.ts => LiveReloadRspackPlugin.ts} | 21 ++++++++++++++++++- .../common/.config/rspack/rspack.config.ts | 2 +- 4 files changed, 39 insertions(+), 5 deletions(-) rename packages/create-plugin/templates/common/.config/rspack/{liveReloadPlugin.ts => LiveReloadRspackPlugin.ts} (79%) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index 4105095680..acda76fcda 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -159,7 +159,9 @@ describe('rspack', () => { expect(result.getFile('.config/rspack/rspack.config.ts')).toBe('// rendered template stub'); expect(result.getFile('.config/rspack/BuildModeRspackPlugin.ts')).toBe('// rendered template stub'); - expect(result.getFile('.config/rspack/liveReloadPlugin.ts')).toBe('// rendered template stub'); + expect(result.getFile('.config/rspack/LiveReloadRspackPlugin.ts')).toBe('// rendered template stub'); + // renamed in the template, so the experimental setup's copy is removed + expect(result.doesFileExist('.config/rspack/liveReloadPlugin.ts')).toBe(false); }); }); @@ -191,7 +193,8 @@ describe('rspack', () => { 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); + expect(result.doesFileExist('.config/rspack/LiveReloadRspackPlugin.ts')).toBe(true); + expect(result.doesFileExist('.config/rspack/liveReloadPlugin.ts')).toBe(false); expect(result.doesFileExist('.config/rspack/LicenseRspackPlugin.ts')).toBe(true); }); }); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 5840f0204b..4dbf12954c 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -19,10 +19,13 @@ const RSPACK_TEMPLATE_DATA_OVERRIDES = { const RSPACK_CONFIG_FILES = [ '.config/rspack/rspack.config.ts', '.config/rspack/BuildModeRspackPlugin.ts', - '.config/rspack/liveReloadPlugin.ts', + '.config/rspack/LiveReloadRspackPlugin.ts', '.config/rspack/LicenseRspackPlugin.ts', ]; +// files the rspack template used to ship under another name. removed so they do not linger +const RETIRED_RSPACK_FILES = ['.config/rspack/liveReloadPlugin.ts']; + const BUNDLER_FILES = [ '.config/bundler/constants.ts', '.config/bundler/copyFiles.ts', @@ -63,6 +66,7 @@ export default function rspack(context: Context): Context { const followUps: string[] = []; + deleteRetiredRspackFiles(context); renderTemplateFiles(context, RSPACK_CONFIG_FILES); renderTemplateFiles(context, BUNDLER_FILES); updateCprcConfig(context); @@ -75,6 +79,14 @@ export default function rspack(context: Context): Context { return context; } +function deleteRetiredRspackFiles(context: Context): void { + for (const filePath of RETIRED_RSPACK_FILES) { + if (context.doesFileExist(filePath)) { + context.deleteFile(filePath); + } + } +} + function reportFollowUps(context: Context, followUps: string[]): void { if (followUps.length === 0) { return; diff --git a/packages/create-plugin/templates/common/.config/rspack/liveReloadPlugin.ts b/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts similarity index 79% rename from packages/create-plugin/templates/common/.config/rspack/liveReloadPlugin.ts rename to packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts index 397d5d17a5..9a979ce6f4 100644 --- a/packages/create-plugin/templates/common/.config/rspack/liveReloadPlugin.ts +++ b/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts @@ -14,13 +14,15 @@ class RspackLiveReloadPlugin { options: RspackLiveReloadPluginOptions; httpServer: ReturnType | null = null; server: WebSocketServer | null = null; + // each rebuild retries the port, so live reload starts by itself once the port is free again + hasWarnedDisabled = false; constructor(options = {}) { this.options = Object.assign( { port: 35729, delay: 0, appendScriptTag: true, - protocol: 'http', + protocol: 'ws', }, options ); @@ -62,6 +64,23 @@ class RspackLiveReloadPlugin { }); this.server = new WebSocketServer({ server: this.httpServer }); + // live reload is a convenience, so a port that is already taken must not take the watch build down. + // ws re-emits the http server's errors on the WebSocketServer, so both need a listener + const disableLiveReload = (error: NodeJS.ErrnoException) => { + if (!this.httpServer) { + return; + } + if (!this.hasWarnedDisabled) { + this.hasWarnedDisabled = true; + const reason = error.code === 'EADDRINUSE' ? `port ${port} is already in use` : error.message; + console.warn(`LiveReload disabled: ${reason}. The build keeps watching without reloading the browser.`); + } + this.server?.close(); + this.server = null; + this.httpServer = null; + }; + this.httpServer.on('error', disableLiveReload); + this.server.on('error', disableLiveReload); this.httpServer.listen(port, () => { console.log(`LiveReload server started on http://localhost:${port}`); }); diff --git a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts index b389727bc8..7eb2464fd6 100644 --- a/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts +++ b/packages/create-plugin/templates/common/.config/rspack/rspack.config.ts @@ -19,7 +19,7 @@ import path from 'path'; import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; import { RspackVirtualModulePlugin } from 'rspack-plugin-virtual-module'; -import RspackLiveReloadPlugin from './liveReloadPlugin.ts'; +import RspackLiveReloadPlugin from './LiveReloadRspackPlugin.ts'; import { BuildModeRspackPlugin } from './BuildModeRspackPlugin.ts'; import { LicenseRspackPlugin } from './LicenseRspackPlugin.ts'; import { DIST_DIR, SOURCE_DIR } from '../bundler/constants.ts'; From e87127ad076307b9877e90940283c91fd660ee5c Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 1 Oct 2026 08:59:47 +0200 Subject: [PATCH 23/25] refactor: drop unused HTML injection from the rspack live reload plugin Grafana plugins emit no HTML. The scaffolded Grafana image adds the livereload script to Grafana's own index.html, so the processAssets step that rewrote .html assets never ran. Remove it with the unused delay, appendScriptTag and protocol options. This also clears the type errors that code had. --- .../.config/rspack/LiveReloadRspackPlugin.ts | 87 ++++--------------- 1 file changed, 15 insertions(+), 72 deletions(-) diff --git a/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts b/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts index 9a979ce6f4..860151f749 100644 --- a/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts +++ b/packages/create-plugin/templates/common/.config/rspack/LiveReloadRspackPlugin.ts @@ -1,48 +1,28 @@ -import path from 'path'; -import { Compilation, type Compiler } from '@rspack/core'; -import { WebSocketServer } from 'ws'; import { createServer } from 'http'; +import { type Compiler } from '@rspack/core'; +import { WebSocketServer } from 'ws'; interface RspackLiveReloadPluginOptions { port?: number; - delay?: number; - appendScriptTag?: boolean; - protocol?: string; } +// Serves /livereload.js and tells connected browsers to reload after every rebuild. The scaffolded +// Grafana docker image adds `); - - compilation.updateAsset(filename, { - source: () => updatedSource, - buffer: () => Buffer.from(updatedSource), - size: () => updatedSource.length, - map: () => null, - sourceAndMap: () => ({ source: updatedSource, map: null }), - updateHash: (hash) => { - hash.update(updatedSource); - }, - }); - } - }); - } - ); - } - _getLiveReloadScript() { return ` (function() { if (typeof WebSocket === 'undefined') return; - const ws = new WebSocket('${this.options.protocol}://localhost:${this.options.port}'); + const ws = new WebSocket('ws://localhost:${this.port}'); ws.onmessage = function(event) { const data = JSON.parse(event.data); if (data.action === 'reload') { From 2e928d784eeb9c365da5894daf3d2e08dbf790bc Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 1 Oct 2026 09:01:38 +0200 Subject: [PATCH 24/25] fix: describe the right bundler in .config docs for rspack plugins The agent instructions told agents in rspack plugins that they must use webpack, and the .config README only explained how to extend the webpack config. The rspack addition's own agent reads those instructions first. Render both files for the plugin's bundler, and have the addition re-render them with the rest of .config. --- .../codemods/additions/scripts/rspack.test.ts | 13 +++++ .../src/codemods/additions/scripts/rspack.ts | 8 ++- .../common/.config/AGENTS/instructions.md | 4 +- .../templates/common/.config/README.md | 56 +++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts index acda76fcda..867c000d32 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.test.ts @@ -199,6 +199,19 @@ describe('rspack', () => { }); }); + describe('docs for agents and developers', () => { + it('should re-render the agent instructions and .config README for rspack', () => { + const context = createBaseContext(); + context.addFile('.config/AGENTS/instructions.md', '**You must use webpack**'); + context.addFile('.config/README.md', '### Extending the Webpack config'); + + const result = addRspack(context); + + expect(result.getFile('.config/AGENTS/instructions.md')).toBe('// rendered template stub'); + expect(result.getFile('.config/README.md')).toBe('// rendered template stub'); + }); + }); + describe('bundler files', () => { it('should update externals.ts with rspack imports', () => { const context = createBaseContext(); diff --git a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts index 4dbf12954c..cc522dcce2 100644 --- a/packages/create-plugin/src/codemods/additions/scripts/rspack.ts +++ b/packages/create-plugin/src/codemods/additions/scripts/rspack.ts @@ -26,6 +26,9 @@ const RSPACK_CONFIG_FILES = [ // files the rspack template used to ship under another name. removed so they do not linger const RETIRED_RSPACK_FILES = ['.config/rspack/liveReloadPlugin.ts']; +// markdown in .config that describes the bundler, for agents and for developers +const DOC_FILES = ['.config/AGENTS/instructions.md', '.config/README.md']; + const BUNDLER_FILES = [ '.config/bundler/constants.ts', '.config/bundler/copyFiles.ts', @@ -69,6 +72,7 @@ export default function rspack(context: Context): Context { deleteRetiredRspackFiles(context); renderTemplateFiles(context, RSPACK_CONFIG_FILES); renderTemplateFiles(context, BUNDLER_FILES); + renderTemplateFiles(context, DOC_FILES, false); updateCprcConfig(context); followUps.push(...addRootRspackConfigStub(context)); @@ -120,9 +124,9 @@ const resolveTemplatePath = (relativePath: string) => fileURLToPath(new URL(`../../../../templates/common/${relativePath}`, import.meta.url)); // updateFile is a no-op for identical content, so re-rendering on every run keeps the addition idempotent -function renderTemplateFiles(context: Context, filePaths: string[]): void { +function renderTemplateFiles(context: Context, filePaths: string[], includeWarning = true): void { for (const filePath of filePaths) { - const rendered = renderTemplate(resolveTemplatePath(filePath), true, RSPACK_TEMPLATE_DATA_OVERRIDES); + const rendered = renderTemplate(resolveTemplatePath(filePath), includeWarning, RSPACK_TEMPLATE_DATA_OVERRIDES); if (context.doesFileExist(filePath)) { context.updateFile(filePath, rendered); } else { diff --git a/packages/create-plugin/templates/common/.config/AGENTS/instructions.md b/packages/create-plugin/templates/common/.config/AGENTS/instructions.md index 362b9dd109..b33c246314 100644 --- a/packages/create-plugin/templates/common/.config/AGENTS/instructions.md +++ b/packages/create-plugin/templates/common/.config/AGENTS/instructions.md @@ -28,7 +28,7 @@ Your training data about the Grafana API is out of date. Use the official docume - **Do not change plugin ID or plugin type** in `plugin.json`. - Any modifications to `plugin.json` require a **restart of the Grafana server**. Remind the user of this. - Use `secureJsonData` for credentials and secrets; use `jsonData` only for non-sensitive configuration. -- **You must use webpack** with the configuration provided in `.config/` for frontend builds. +- **You must use {{frontendBundler}}** with the configuration provided in `.config/` for frontend builds. - **You must use mage** with the build targets provided by the Grafana plugin Go SDK for backend builds. -- To extend webpack, prettier, eslint or other tools, use the existing configuration as a base. Follow the guide: https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations.md +- To extend {{frontendBundler}}, prettier, eslint or other tools, use the existing configuration as a base. Follow the guide: https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations.md - Use **`@grafana/plugin-e2e`** for end-to-end testing. Read @./.config/AGENTS/e2e-testing.md before writing or modifying e2e tests. diff --git a/packages/create-plugin/templates/common/.config/README.md b/packages/create-plugin/templates/common/.config/README.md index 93d9a3bc85..eb0acd92d8 100644 --- a/packages/create-plugin/templates/common/.config/README.md +++ b/packages/create-plugin/templates/common/.config/README.md @@ -99,6 +99,61 @@ Edit the `tsconfig.json` file in the project root in order to extend the TypeScr --- +{{#if useExperimentalRspack}} +### Extending the Rspack config + +Follow these steps to extend the basic Rspack configuration that lives under `.config/`: + +#### 1. Create a new Rspack configuration file + +Create a new config file that is going to extend the basic one provided by Grafana. +It can live in the project root, e.g. `rspack.config.ts`. + +#### 2. Merge the basic config provided by Grafana and your custom setup + +We are going to use [`rspack-merge`](https://github.com/rstackjs/rspack-merge) for this. + +```typescript +// rspack.config.ts +import type { Configuration } from '@rspack/core'; +import { merge } from 'rspack-merge'; +import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts'; + +const config = async (env: Env): Promise => { + const baseConfig = await grafanaConfig(env); + + return merge(baseConfig, { + // Add custom config here... + output: { + asyncChunks: true, + }, + }); +}; + +export default config; +``` + +The Rspack CLI loads this file as native ESM, so use `import.meta.dirname` instead of `__dirname` and give relative imports a `.ts` extension. + +#### 3. Update the `package.json` to use the new Rspack config + +We need to update the `scripts` in the `package.json` to use the extended Rspack configuration. + +**Update for `build`:** + +```diff +-"build": "rspack -c ./.config/rspack/rspack.config.ts --env production", ++"build": "rspack -c ./rspack.config.ts --env production", +``` + +**Update for `dev`:** + +```diff +-"dev": "rspack -w -c ./.config/rspack/rspack.config.ts --env development", ++"dev": "rspack -w -c ./rspack.config.ts --env development", +``` + +{{else}} ### Extending the Webpack config Follow these steps to extend the basic Webpack configuration that lives under `.config/`: @@ -150,6 +205,7 @@ We need to update the `scripts` in the `package.json` to use the extended Webpac +"dev": "webpack -w -c ./webpack.config.ts --env development", ``` +{{/if}} ### Configure grafana image to use when running docker By default, `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behavior, simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`. From 331db5b7dbebee2b0891fe3782c955ef882e80f0 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Fri, 2 Oct 2026 10:50:20 +0200 Subject: [PATCH 25/25] feat: run typecheck and build after the rspack agent step The rspack prompt's Verify step asks the agent to check its own port. Registering the scripts in verify makes add run them itself once the session ends. --- docusaurus/docs/how-to-guides/add-features-to-a-plugin.md | 2 +- packages/create-plugin/src/codemods/additions/additions.test.ts | 1 + packages/create-plugin/src/codemods/additions/additions.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md b/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md index 4a967c5bb8..596441b10a 100644 --- a/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md +++ b/docusaurus/docs/how-to-guides/add-features-to-a-plugin.md @@ -120,7 +120,7 @@ It's a hybrid addition: If you run the addition with `--no-agent` and your plugin has its own webpack configuration, the root `rspack.config.ts` makes your build fail with an `[rspack]` error until you port your customizations. This stops you from releasing a build that's missing them. -After the addition, check that your plugin still builds and runs: +After the addition, check that your plugin still builds and runs. If `create-plugin` started the agent, it has already run your `typecheck` and `build` scripts. ```shell npm run typecheck diff --git a/packages/create-plugin/src/codemods/additions/additions.test.ts b/packages/create-plugin/src/codemods/additions/additions.test.ts index a5bebd201f..5c5aad4c6a 100644 --- a/packages/create-plugin/src/codemods/additions/additions.test.ts +++ b/packages/create-plugin/src/codemods/additions/additions.test.ts @@ -62,6 +62,7 @@ describe('additions json', () => { expect(rspack).toBeDefined(); expect(isScriptAddition(rspack!)).toBe(true); expect(hasPromptStep(rspack!)).toBe(true); + expect(rspack!.verify).toEqual(['typecheck', 'build']); }); defaultAdditions.forEach((addition) => { diff --git a/packages/create-plugin/src/codemods/additions/additions.ts b/packages/create-plugin/src/codemods/additions/additions.ts index c8477afabc..2725edf503 100644 --- a/packages/create-plugin/src/codemods/additions/additions.ts +++ b/packages/create-plugin/src/codemods/additions/additions.ts @@ -48,6 +48,7 @@ const additions: Addition[] = [ description: 'Migrates the plugin frontend build from webpack or experimental rspack to rspack 2', scriptPath: import.meta.resolve('./scripts/rspack.js'), prompt: import.meta.resolve('./prompts/rspack.md'), + verify: ['typecheck', 'build'], }, { name: 'experimental-app-sdk',