Webpack Integration
@resource-fallback/webpack-plugin is a Webpack 5+ plugin that provides runtime retry and multi-CDN fallback for Webpack build outputs (entry scripts, async chunks, CSS).
Installation
pnpm add -D @resource-fallback/webpack-plugin html-webpack-pluginAlso install html-webpack-plugin (v4+) for automatic runtime injection.
Basic configuration
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ResourceFallbackWebpackPlugin } = require('@resource-fallback/webpack-plugin');
module.exports = {
output: {
publicPath: 'https://cdn.example.com/',
},
plugins: [
new HtmlWebpackPlugin(),
new ResourceFallbackWebpackPlugin({
rules: [
{
base: 'https://cdn.example.com/',
urls: [
'https://cdn-backup.example.com/',
'/', // origin fallback
],
},
],
}),
],
};Important
output.publicPath should equal rules[].base (rule base).
Full options: Configuration Reference.
How it works
The plugin does two things at build time; the runtime provides dual-layer protection.
Build time
1. HTML injection
Via html-webpack-plugin's alterAssetTagGroups hook, injects into <head>:
<link rel="preconnect">tags<script>with inlined runtime IIFE +install(config)call
Without html-webpack-plugin
If html-webpack-plugin is not detected, the plugin logs a warning and does not inject automatically. Use @resource-fallback/core's getRuntimeCode() to inject manually.
2. RuntimeModule injection
Injects a Webpack RuntimeModule (stage = STAGE_TRIGGER) that patches __webpack_require__.l inside webpack's bootstrap — after it is defined but before the first chunk load. This is more reliable than external monkey-patching.
The injected code only connects Webpack's loader callbacks to core's private window.__RF__.internal bridge. It is not a public application API and should not be treated as a semver-stable integration surface. The page-side RecoveryCoordinator remains the recovery decision engine and owns retry, fallback, circuit state, events, cancellation, and sharing of in-flight recovery Promises.
Runtime — dual-layer protection
Layer 1: __webpack_require__.l wrapping
All async chunks (including React.lazy(), dynamic import()) load <script> tags through __webpack_require__.l. The RuntimeModule is the primary path: it wraps webpack's native loader inside bootstrap, preserves first-load callback semantics, then hands recovery decisions to the Coordinator. Wrapped flow:
Chunk load request
│
├── __webpack_require__.l(url, done, key, chunkId)
│ │
│ ├── Original <script> load
│ │ ├── Success → Coordinator → done(event)
│ │ └── Failure → Coordinator recovery
│ │ ├── retry → create new <script>, delay and retry
│ │ ├── fallback → create new <script>, switch URL
│ │ └── giveup → done(event) (let webpack handle the error)Concurrent admissions for the same logical chunk (logicalKey derived from key/chunkId) join one Coordinator session and share one recovery Promise instead of launching parallel recoveries. Each retry/fallback creates a brand new <script> element and preserves webpack metadata such as nonce, crossOrigin, referrerPolicy, charset, and trustedScriptUrl; when a key is present, the new node also carries data-webpack.
data-webpack ownership
Retry/fallback <script> elements get a data-webpack attribute (chunk loading key). Observer only skips <script> tags with data-webpack, which prevents duplicate takeover of the same async JS chunk by both Observer and the webpack adapter; this rule does not transfer CSS ownership, so CSS <link> replacement still belongs to Observer.
Ownership split
- Webpack adapter — async chunk
<script>withdata-webpack - Observer — entry scripts (no
data-webpack), CSS chunks, other external<script>
CSS chunk promise handling
mini-css-extract-plugin and webpack experiments.css register non-j loaders on __webpack_require__.f (e.g. miniCss, css). When an async chunk includes a separate .css chunk, __webpack_require__.e(chunkId) runs Promise.all over JS and CSS loader promises.
If CSS <link> load fails, webpack's generated onerror rejects with ChunkLoadError (code: 'CSS_CHUNK_LOAD_FAILED') or an error whose request points at a .css URL. The RuntimeModule does exactly one thing here: swallow that recognized CSS rejection so Promise.all does not fail early. The actual <link> replacement still belongs to Observer. In other words, the webpack hook prevents premature failure, while Observer creates and owns the replacement CSS node.
The injected RuntimeModule wraps every non-j loader on __webpack_require__.f:
// Simplified injected logic
for (const fk of Object.keys(__webpack_require__.f)) {
if (fk === 'j') continue;
const origFn = __webpack_require__.f[fk];
__webpack_require__.f[fk] = function (chunkId, promises) {
const before = promises.length;
origFn(chunkId, promises);
for (let pi = before; pi < promises.length; pi++) {
promises[pi] = promises[pi].catch((err) => {
const isCss =
err?.code === 'CSS_CHUNK_LOAD_FAILED' ||
(err?.request && /\.css([?#]|$)/.test(err.request));
if (!isCss) throw err;
// The Observer owns the CSS link replacement. Swallow the original
// rejection so Promise.all does not fail before that replacement.
});
}
};
}CSS entity loading still relies on Observer for <link> replacement — same ownership split as async JS scripts.
Layer 2: Observer
Observer acts as a safety net for scenarios __webpack_require__.l does not cover:
- Entry scripts (no
data-webpack) - CSS chunks (
<link>from mini-css-extract-plugin) - Other external
<script>tags
chunkLoadingGlobal hook
core's webpack adapter also hooks window[chunkLoadingGlobal] push as a fallback path: once chunk runtime code exposes __webpack_require__, it can wrap __webpack_require__.l from the page side. The global name comes from output.chunkLoadingGlobal when set, otherwise from webpack's uniqueName-derived default, so it is not always literally webpackChunk_.
This chunk-array / chunkLoadingGlobal path is a backup, not a replacement for the RuntimeModule primary path. If the RuntimeModule misses due to version or injection timing, the page-side adapter can still take over; if the plugin RuntimeModule already wrapped .l, the chunk-array adapter detects the marker and yields.
Configuration example
new ResourceFallbackWebpackPlugin({
rules: [
{
base: 'https://cdn.example.com/',
urls: ['https://cdn-backup.example.com/', 'https://static.mysite.com/', '/'],
retry: { max: 2, baseDelay: 300, maxDelay: 3000, jitter: true },
circuit: { threshold: 3, cooldown: 30000 },
},
],
debug: 'auto',
sri: 'strip',
nonce: 'my-csp-nonce',
injectPreconnect: true,
});Notes
Non-browser targets
When target is node / webworker / electron-main, the plugin skips injection.
React.lazy error handling
If an async chunk exhausts all candidate URLs, React.lazy() throws. <Suspense> only handles loading, not errors. Wrap with ErrorBoundary:
class ChunkErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
state = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return <div>Resource load failed. Please refresh the page.</div>;
}
return this.props.children;
}
}
<ChunkErrorBoundary>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ChunkErrorBoundary>;Entry script fallback
If the entry bundle exhausts all fallbacks, React/Vue never initializes. But rf:error can also happen after boot for later resources or for SW-bridged failures, so do not replace the whole page for every rf:error. Add an inline listener in index.html that filters to the known entry resource, then remove it after the app boots:
<p id="rf-entry-fallback" hidden>Resource load failed. Please refresh the page.</p>
<script>
(function () {
var expectedEntry = 'https://cdn.example.com/assets/main.js';
function onRfError(event) {
var detail = event.detail || {};
if (detail.url !== expectedEntry) return;
var fallback = document.getElementById('rf-entry-fallback');
if (fallback) fallback.hidden = false;
}
window.addEventListener('rf:error', onRfError);
// Remove this from your app entry after successful boot:
// window.removeEventListener('rf:error', onRfError);
})();
</script>dispose cleanup
When window.__RF__.dispose() runs, or the current runtime installation is torn down, the webpack owner cancels in-flight recovery and removes the __webpack_require__.l wrapper, chunk-array push patches, scan timers, and listeners. Old sessions do not continue taking over page state after a fresh install; a new runtime installation creates its own Coordinator and adapters.
Sync/async coverage
| Scenario | Webpack |
|---|---|
Sync <script> / <link> | ✓ Observer |
Async chunk (import()) | ✓ __webpack_require__.l hook |
| CSS dynamic injection | ✓ Observer |
| SystemJS (legacy bundle) | ✓ instantiate hook |
| Images / fonts / media | ✓ Hybrid SW (opt-in, controlled pages) |
CSS url() / @font-face | ✓ Hybrid SW (opt-in, controlled pages) |
CSS @import | ✓ Hybrid SW (CSS referrer must match manifest) |