Vite Integration
@resource-fallback/vite-plugin is a Vite 4+ plugin that provides runtime retry and multi-CDN fallback for Vite build outputs (sync JS/CSS, async chunks, modulepreload).
Installation
pnpm add -D @resource-fallback/vite-pluginBasic configuration
// vite.config.ts
import { defineConfig } from 'vite';
import resourceFallback from '@resource-fallback/vite-plugin';
export default defineConfig({
base: 'https://cdn.example.com/',
plugins: [
resourceFallback({
rules: [
{
match: 'https://cdn.example.com/',
urls: [
'https://cdn-backup.example.com/',
'/', // origin fallback
],
},
],
}),
],
});Important
The value of base should match match so build output URLs hit the rule.
Full options: Configuration Reference.
How it works
The plugin does three things at build time:
1. HTML injection
Via transformIndexHtml, injects into <head>:
<link rel="preconnect">tags (pre-connect for each fallback domain)<script>with inlined runtime IIFE +install(config)call
2. renderBuiltUrl
Uses Vite's experimental.renderBuiltUrl hook to rewrite JS asset URLs for runtime resolution:
// Original output
import('/assets/chunk-abc.js');
// Rewritten (in build output)
window.__RF__.url('assets/chunk-abc.js');
// → 'https://cdn.example.com/assets/chunk-abc.js' (or skips unavailable host due to circuit breaker)__RF__.url() resolves the final URL at runtime based on rules and circuit breaker state, skipping tripped hosts.
3. Dynamic import rewriting
The plugin wraps dynamic import() in two ways:
renderDynamicImport
Uses Rollup's renderDynamicImport hook to wrap dynamic import() into a loading function with a fallback loop:
// Original code
const mod = await import('./Lazy.vue');
// After build
const mod = await window.__RF__.load('assets/Lazy-abc.js', import('./Lazy.vue'));writeBundle + es-module-lexer
The writeBundle hook uses es-module-lexer to parse dynamic imports inside chunks and replace match-rule URLs with __RF__.load() calls. This preserves dependency relationships when async modules include CSS.
// writeBundle rewrite example
window.__RF__.load('assets/About-xxx.js');shouldRewriteUrls gate
In configResolved, the plugin compares Vite's final resolved base with rules[].match:
- If
basematches at least one rule'smatch, URL rewriting is enabled (renderBuiltUrl,writeBundle) - Otherwise rewriting is skipped to avoid incorrectly rewriting non-matching resources
Why configResolved
base is read from configResolved to get Vite's final resolved value (after other plugins may override it).
RF.load fallback loop
__RF__.load runs the full retry → fallback loop:
- Determines initial URL via
resolveBuiltUrl - Attempts
import(url) - On failure, retries per config (exponential backoff + jitter)
- After retry budget is exhausted, switches to the next candidate URL
- ES Module retries automatically append
__rf=to bypass browser module cache - Emits
rf:retry/rf:fallback/rf:errorat each step - Throws the original error when all candidates are exhausted
vite:preloadError handling
The runtime listens for Vite's vite:preloadError event. When modulepreload fails:
preventDefault()is required — otherwise Vite throws and blocks subsequent__RF__.load()calls- Payload is read from
event.payload(notdetail) - Records host failure to the circuit breaker
- Lets subsequent
resolveBuiltUrlskip unavailable hosts - For CSS preload failures, CSS entity loading is delegated to Observer via
<link>error events
// Simplified behavior in installViteAdapter
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
const url = extractUrlFromError(event.payload);
if (url) resolver.recordFailure(url);
});Configuration example
resourceFallback({
rules: [
{
match: '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,
htmlInject: 'head-prepend',
});With @vitejs/plugin-legacy
When using @vitejs/plugin-legacy for SystemJS legacy bundles, the runtime installs the SystemJS adapter automatically via System.constructor.prototype.instantiate.
import legacy from '@vitejs/plugin-legacy';
import resourceFallback from '@resource-fallback/vite-plugin';
export default defineConfig({
base: 'https://cdn.example.com/',
plugins: [
legacy({ targets: ['defaults', 'not IE 11'] }),
resourceFallback({
rules: [{ match: 'https://cdn.example.com/', urls: ['/'] }],
}),
],
});Vite dev mode
The plugin is inactive in dev by default (enableDev: false). Vite dev uses native ESM — dynamic import failures cannot be intercepted.
Verification
Use vite build && vite preview to verify fallback. Setting enableDev: true injects the runtime in dev, but only sync <script> / <link> error events work.
Sync/async coverage
| Scenario | Vite (build/preview) | Vite (dev) |
|---|---|---|
Sync <script> / <link> | ✓ Observer | ✓ Observer |
Async chunk (import()) | ✓ __RF__.load + renderDynamicImport | ✗ |
| CSS dynamic injection | ✓ Observer | ✓ Observer |
| SystemJS (legacy bundle) | ✓ instantiate hook | — |
| Images / fonts / media | ✓ Hybrid SW (opt-in) | ✗ |
CSS url() / @font-face | ✓ Hybrid SW (opt-in) | ✗ |
CSS @import | ✓ Hybrid SW (CSS referrer must match manifest) | ✗ |