Webpack to Rspack v2
OHIF 3.13 replaces Webpack with Rspack v2 as the
default bundler for the app, every extension, every mode, and the
@ohif/ui-next / @ohif/ui / @ohif/i18n / @ohif/core packages.
.webpack/ files are now Rspack configsThe directory layout and filenames are unchanged — you will still find
.webpack/webpack.base.js, .webpack/webpack.pwa.js, and a
.webpack/webpack.prod.js in each package. Despite the webpack name,
these files now configure Rspack. They require('@rspack/core') (aliased
to a local webpack variable so the rest of the config reads the same) and
are run by the rspack CLI. The names were kept to minimize churn and keep
custom-extension forks merging cleanly — do not assume a file called
webpack.*.js runs Webpack.
There is no Webpack fallback. Webpack and all of its plugins have been
removed from the dependency tree; the only supported bundler is Rspack
(plus Rsbuild for the dev:fast path, see below).
Why Rspack
Rspack is API-compatible with most of the Webpack v5 plugin ecosystem but written in Rust. For the OHIF tree the practical wins are:
- 3-5x faster cold builds and
--watchrebuilds. - Built-in SWC minification (no separate
terser-webpack-plugin). - First-party drop-in replacements for the plugins that previously
ate the bulk of build time (
MiniCssExtractPlugin,CopyWebpackPlugin,HtmlWebpackPlugin).
New scripts
platform/app/package.json was rewritten to invoke rspack instead of
webpack:
- "build": "node --max_old_space_size=8096 ./../../node_modules/webpack/bin/webpack.js --progress --config .webpack/webpack.pwa.js",
+ "build": "cross-env NODE_OPTIONS=--max-old-space-size=24576 rspack build --config .webpack/webpack.pwa.js",
- "dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.pwa.js",
+ "dev": "cross-env NODE_ENV=development rspack serve --config .webpack/webpack.pwa.js",
- "dev:orthanc": "… webpack serve --config .webpack/webpack.pwa.js",
+ "dev:orthanc": "… rspack serve --config .webpack/webpack.pwa.js"
Notes:
- The build now requests
--max-old-space-size=24576(24 GB) viaNODE_OPTIONS. The previous 8 GB limit is no longer enough for full prod builds. webpack serveis replaced byrspack serve. Alldev:*variants (dev:orthanc,dev:dcm4chee,dev:static, …) were updated the same way.dev:no:cacheno longer passes--no-cache(Rspack's CLI does not expose it). It is now identical todev; production caching is disabled unconditionally in the config instead (see "Caching" below).dev:fastruns Rsbuild rather than Rspack directly (rsbuild dev --config ../../rsbuild.config.ts). Rsbuild is the higher-level toolchain built on Rspack; it is used only for the fast dev-server path and is configured separately inrsbuild.config.ts.
Dependency changes
platform/app/package.json (and every other workspace package that ships a
.webpack/webpack.prod.js) adds:
{
"devDependencies": {
"@rspack/cli": "^2.0.0",
"@rspack/core": "^2.0.0",
"@rspack/dev-server": "^2.0.0",
"@rspack/plugin-react-refresh": "^2.0.0"
}
}
and removes the Webpack toolchain that is no longer used:
- "webpack": "5.105.0",
- "@pmmmwh/react-refresh-webpack-plugin": "0.5.17",
- "clean-webpack-plugin": "4.0.0",
- "copy-webpack-plugin": "10.2.4",
- "html-webpack-plugin": "5.6.3",
- "terser-webpack-plugin": "5.3.14",
- "webpack-dev-server": "5.2.2",
- "workbox-webpack-plugin": "6.6.1",
- "dotenv-webpack": "1.8.0",
- "extract-css-chunks-webpack-plugin": "4.10.0",
(webpack-merge is kept — Rspack configs still use it to merge the base
and per-package configs.)
The root rsbuild.config.ts path additionally depends on @rsbuild/core,
@rsbuild/plugin-react, and @rsbuild/plugin-node-polyfill.
Shared base config (.webpack/webpack.base.js)
webpack.base.js is the file most consumers extend in their own
extensions. It now requires @rspack/core instead of webpack:
- const webpack = require('webpack');
+ const webpack = require('@rspack/core');
Rspack exports the same DefinePlugin, ProvidePlugin, and
IgnorePlugin constructors under the same names, so most plugin code
is unchanged — which is why the local variable is still called webpack.
Plugin replacements
| 3.12 (Webpack) | 3.13 (Rspack) |
|---|---|
mini-css-extract-plugin | require('@rspack/core').CssExtractRspackPlugin |
clean-webpack-plugin | output: { clean: true } |
copy-webpack-plugin | require('@rspack/core').CopyRspackPlugin |
html-webpack-plugin | require('@rspack/core').HtmlRspackPlugin |
@pmmmwh/react-refresh-webpack-plugin | require('@rspack/plugin-react-refresh') |
terser-webpack-plugin | Built-in SwcJsMinimizerRspackPlugin (no config needed) |
workbox-webpack-plugin (InjectManifest) | Custom InjectServiceWorkerManifestPlugin in webpack.pwa.js |
dotenv-webpack | Plain require('dotenv').config() |
InjectServiceWorkerManifestPlugin is a small inline plugin that
re-implements what workbox-webpack-plugin's InjectManifest did, but
on top of Rspack's compilation hooks (thisCompilation →
processAssets, emitting a RawSource). It is defined locally in
platform/app/.webpack/webpack.pwa.js — copy it into your own
webpack.pwa.js derivative if you forked that file.
The React Refresh plugin is loaded defensively (try/require) and is
skipped when it is unavailable, in production, or during e2e coverage
runs (COVERAGE=true), since the refresh runtime's overlay iframe
interferes with Playwright/Cypress pointer events.
Library output
Every package-level webpack.prod.js switched from the legacy library
flags to the structured output.library form:
output: {
- library: 'ohif-extension-cornerstone',
- libraryTarget: 'umd',
+ library: {
+ name: 'ohif-extension-cornerstone',
+ type: 'umd',
+ },
path: ROOT_DIR,
filename: pkg.main
}
If your extension uses the old flat library / libraryTarget keys,
move to the nested form — Rspack is stricter about validating this shape.
Minifier
Terser is gone. Production builds use Rspack's built-in SWC minifier unconditionally:
if (isProdBuild) {
- config.optimization.minimizer = [
- new TerserJSPlugin({ parallel: true, terserOptions: {} }),
- ];
+ config.optimization.minimizer = [new webpack.SwcJsMinimizerRspackPlugin()];
}
No options are needed for the common case. If you previously tuned
terserOptions, port the equivalent settings to the SWC minimizer's
options object.
Source maps
The devtool setting is unchanged from 3.12 — production builds still
emit full source-map, development uses cheap-module-source-map, and a
QUICK_BUILD=true build disables source maps and minification entirely
(config.devtool = false):
devtool: isProdBuild ? 'source-map' : 'cheap-module-source-map',
// …
if (isQuickBuild) {
config.optimization.minimize = false;
config.devtool = false;
}
Caching
- cache: {
- type: 'filesystem',
- },
+ cache: isProdBuild ? false : { type: 'filesystem' },
Production builds always run from a clean cache. The development
filesystem cache is unchanged, but the cache directory is no longer
shared with Webpack — clear .cache/ after upgrading if you see stale
output.
IgnorePlugin for native modules
A new IgnorePlugin entry was added to skip Node-only modules pulled
in by the Cornerstone codecs:
new webpack.IgnorePlugin({
resourceRegExp: /^(fs|path)$/,
contextRegExp: /@cornerstonejs[\\/]codec-/,
}),
If you removed this when forking webpack.base.js, add it back —
without it the prod bundle will try to require fs at runtime.
Node globals (__filename / __dirname)
A new top-level node block tells the bundler to leave __filename and
__dirname references un-substituted rather than mocking them:
node: {
__filename: false,
__dirname: false,
},
The Emscripten-compiled Cornerstone codecs reference __dirname inside
if (ENVIRONMENT_IS_NODE) branches that never run in the browser. Rspack's
default (a 'mock' value) emits a warning for each such reference; setting
the values to false leaves them alone, which is harmless at runtime and
silences the warnings. The same node block is mirrored in
rsbuild.config.ts for the dev:fast path (Rsbuild's default is
warn-mock, with the same noisy behavior).
Workspace package transpile
.webpack/rules/transpileJavaScript.js no longer treats @ohif/*
packages as opaque node_modules:
mode === 'production'
? excludeNodeModulesExcept([
+ // Workspace packages (needed for pnpm shamefully-hoist where they
+ // resolve through node_modules)
+ '@ohif',
'react-dnd',
'dnd-core',
pnpm symlinks workspace packages through node_modules, so the
transpile rule has to opt them back in or the production bundle would
ship un-transpiled TypeScript. Custom monorepos that vendor extensions
under a different scope should add their own scope here.
Module resolution for pnpm
Two resolution changes were needed for pnpm's isolated (non-hoisted)
node_modules layout. Both live in resolve in webpack.base.js (and
webpack.pwa.js):
-
resolve.modulesnow leads with a bare'node_modules'before the absolute paths. This preserves the default importer-relative walk-up so transitive deps (e.g.react-remove-scroll→tslib) resolve to the sibling copy inside.pnpm/<pkg>/node_modulesrather than an older hoisted one.modules: [+ 'node_modules',path.resolve(__dirname, '../node_modules'),path.resolve(__dirname, '../../../node_modules'),// …], -
A new
'@ohif/app$'alias maps the bare specifier to the app source. A couple of extensions import app-level utilities from@ohif/app; pnpm's isolated layout does not expose the top-level app package to them, and adding it as a workspace dependency would create anapp ↔ defaultcycle, so the alias resolves it directly (the$makes it an exact match, so deep subpath imports still resolve normally):'@ohif/app$': path.resolve(__dirname, '../platform/app/src/index.js'),
Plugin resolution from source (writePluginImportsFile.js)
Under yarn the app depended on every extension/mode and copied their
public/ and dist/ assets out of node_modules. Under pnpm + Rspack,
extensions and modes are not dependencies of platform/app; instead
writePluginImportsFile.js resolves the source directory of each plugin
declared in pluginConfig.json. It scans the extensions/ and modes/
workspaces only to map the declared package names to their directories —
packages present in those workspaces but not listed in pluginConfig.json
are ignored. The resulting map is exposed two ways:
getPluginResolveAliases()returns aresolve.aliasmap (one exact-match"<pkg>$"entry per plugin inpluginConfig.json) thatwebpack.pwa.jsmerges intoresolve.alias, so the generatedpluginImports.jsimport()s link to the plugin source without the plugin being a dependency.createCopyPluginToDist(...)copies each plugin'spublic/anddist/assets from that same source directory (falling back tonode_modulesfor third-party entries such asdicom-microscopy-viewer).
A plugin can be included three ways, all declared as an entry in
pluginConfig.json:
- In-tree workspace — a package under
extensions/ormodes/. Declare it bypackageName; its source directory is found by the workspace scan. - External, out-of-tree source — a checkout that lives outside this repo
(e.g. an extension generated by the OHIF CLI). Add a
directoryfield to the entry. The path may be absolute,~-relative to the home directory, or.-relative to the repo root;workspacePluginDir()uses it directly and skips the workspace scan. - Installed dependency — add the package to the root
package.jsonas a normal dependency and declare it bypackageName(nodirectory). It then resolves fromnode_moduleslike any other installed package: the bare specifier flows through webpack's normal module walk-up (no alias is generated), andpluginAssetDir()copies itspublic//dist/assets fromnode_modules. This is the path used for third-party packages such asdicom-microscopy-viewer.
If you maintain a fork that injects extensions a different way, this is the seam to update.
Per-package webpack.prod.js
For every workspace package that previously had a webpack.prod.js,
update the top of the file:
- const webpack = require('webpack');
+ const webpack = require('@rspack/core');
const { merge } = require('webpack-merge');
- const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+ const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin;
and replace the flat library options with the nested form shown above.
The rest of the file (merge(...), entry, externals, output.path,
output.filename) is unchanged.
Custom extensions
If you maintain an out-of-tree OHIF extension that uses the OHIF template, do the following:
- Add
@rspack/cli,@rspack/core,@rspack/dev-server, and@rspack/plugin-react-refreshtodevDependencies(^2.0.0), and removewebpack,webpack-dev-server, and the webpack-specific plugins (mini-css-extract-plugin,copy-webpack-plugin,html-webpack-plugin,clean-webpack-plugin,terser-webpack-plugin,@pmmmwh/react-refresh-webpack-plugin,workbox-webpack-plugin,dotenv-webpack). - Replace
require('webpack')withrequire('@rspack/core')in your.webpack/*.jsfiles (you can keep the local variable namedwebpack). - Update plugin imports as shown in the table above, and switch the flat
library/libraryTargetkeys to the nestedoutput.libraryform. - Change your build script from
webpacktorspack build(andwebpack servetorspack serve). - If you re-export the OHIF base config, re-pull it after upgrading —
the
IgnorePlugin,nodeblock,transpileJavaScript, and pnpm resolution changes only land when you re-merge.
Known migration notes
-
@million/lintintegration is removed fromwebpack.pwa.js(it was already commented out in 3.12). -
Dotenvplugin is replaced by a top-leveldotenv.config()call. If you relied on the plugin'ssafe: truebehavior, move that check into your config loader. -
Dev server proxy moved from the object-keyed shape to the array-of-
{ context, target }shape that@rspack/dev-serverexpects:- proxy: [{ '/dicomweb': 'http://localhost:5000' }],+ proxy: [{ context: ['/dicomweb'], target: 'http://localhost:5000' }], -
Dev-server overlay is disabled when
COVERAGE=true(the overlay iframe intercepts pointer events and breaks Playwright/Cypress clicks); it is kept on for normal local dev. -
The
dev:no:cachescript is now identical todev— keep it as an alias if external scripts call it, or delete it.