Skip to main content
Version: 3.13 Beta (Latest)

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.

The .webpack/ files are now Rspack configs

The 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 --watch rebuilds.
  • 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) via NODE_OPTIONS. The previous 8 GB limit is no longer enough for full prod builds.
  • webpack serve is replaced by rspack serve. All dev:* variants (dev:orthanc, dev:dcm4chee, dev:static, …) were updated the same way.
  • dev:no:cache no longer passes --no-cache (Rspack's CLI does not expose it). It is now identical to dev; production caching is disabled unconditionally in the config instead (see "Caching" below).
  • dev:fast runs 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 in rsbuild.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-pluginrequire('@rspack/core').CssExtractRspackPlugin
clean-webpack-pluginoutput: { clean: true }
copy-webpack-pluginrequire('@rspack/core').CopyRspackPlugin
html-webpack-pluginrequire('@rspack/core').HtmlRspackPlugin
@pmmmwh/react-refresh-webpack-pluginrequire('@rspack/plugin-react-refresh')
terser-webpack-pluginBuilt-in SwcJsMinimizerRspackPlugin (no config needed)
workbox-webpack-plugin (InjectManifest)Custom InjectServiceWorkerManifestPlugin in webpack.pwa.js
dotenv-webpackPlain 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 (thisCompilationprocessAssets, 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.modules now 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-scrolltslib) resolve to the sibling copy inside .pnpm/<pkg>/node_modules rather 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 an app ↔ default cycle, 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 a resolve.alias map (one exact-match "<pkg>$" entry per plugin in pluginConfig.json) that webpack.pwa.js merges into resolve.alias, so the generated pluginImports.js import()s link to the plugin source without the plugin being a dependency.
  • createCopyPluginToDist(...) copies each plugin's public/ and dist/ assets from that same source directory (falling back to node_modules for third-party entries such as dicom-microscopy-viewer).

A plugin can be included three ways, all declared as an entry in pluginConfig.json:

  1. In-tree workspace — a package under extensions/ or modes/. Declare it by packageName; its source directory is found by the workspace scan.
  2. External, out-of-tree source — a checkout that lives outside this repo (e.g. an extension generated by the OHIF CLI). Add a directory field 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.
  3. Installed dependency — add the package to the root package.json as a normal dependency and declare it by packageName (no directory). It then resolves from node_modules like any other installed package: the bare specifier flows through webpack's normal module walk-up (no alias is generated), and pluginAssetDir() copies its public//dist/ assets from node_modules. This is the path used for third-party packages such as dicom-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:

  1. Add @rspack/cli, @rspack/core, @rspack/dev-server, and @rspack/plugin-react-refresh to devDependencies (^2.0.0), and remove webpack, 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).
  2. Replace require('webpack') with require('@rspack/core') in your .webpack/*.js files (you can keep the local variable named webpack).
  3. Update plugin imports as shown in the table above, and switch the flat library/libraryTarget keys to the nested output.library form.
  4. Change your build script from webpack to rspack build (and webpack serve to rspack serve).
  5. If you re-export the OHIF base config, re-pull it after upgrading — the IgnorePlugin, node block, transpileJavaScript, and pnpm resolution changes only land when you re-merge.

Known migration notes

  • @million/lint integration is removed from webpack.pwa.js (it was already commented out in 3.12).

  • Dotenv plugin is replaced by a top-level dotenv.config() call. If you relied on the plugin's safe: true behavior, 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-server expects:

    - 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:cache script is now identical to dev — keep it as an alias if external scripts call it, or delete it.