Skip to content

Commit 2f6617f

Browse files
authored
feat: import map integrity mode — native SRI for module chunks (#35)
* feat: add import map integrity key builders * feat: inject import map integrity into emitted HTML * feat: let import map integrity subsume JS-level dynamic import enforcement * docs: document import map integrity mode and runtime fallback * fix: harden import map integrity per review findings - honor skipResources in the import map integrity object (the opt-out now applies to native module-fetch enforcement too) - keep the JS enforcement rewrite when a manifest is emitted alongside HTML: manifest consumers render their own HTML without the import map, so suppressing the rewrite would silently drop their enforcement - warn when an existing import map pins an integrity value that differs from the build-computed hash (kept, but surfaced) - warn when user HTML contains an absolute-URL <base href>, which changes how import map keys resolve - match PROCESSABLE_EXTENSIONS query-suffix semantics in the key filter - share one escapeForScript implementation between the runtime serializer and the import map injector - log the relative-base skip once per build instead of once per file * docs: correct Safari support to 18+ and document review-found boundaries MDN browser-compat-data records import map integrity as Safari 18.0 (18.4 is the adjacent multiple-import-maps row). Also: import map ToC entry, mixed HTML+manifest fallback, runtimePatchDynamicLinks:false old-browser boundary, skipResources exclusion note, facade phrasing. * fix: keep a leading meta charset ahead of injected import map and preload links The import map (and the modulepreload links) were unshifted to the very start of head, displacing <meta charset> — which the HTML spec wants within the first 1024 bytes. Both grow with chunk count, so a large bundle could push the charset declaration past that boundary and force encoding sniffing. Both injection sites now insert after a leading charset-declaring meta (charset attribute or http-equiv content-type) when it precedes any script or link element, falling back to the head start otherwise. The import map still lands before every injected link and module script.
1 parent 5c7d2c3 commit 2f6617f

5 files changed

Lines changed: 778 additions & 36 deletions

File tree

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
- [Configuration](#configuration)
4444
- [Skipping Resources](#skipping-resources)
4545
- [Lazy-loaded Chunks and Dynamic Tags](#lazy-loaded-chunks-and-dynamic-tags)
46+
- [Import Map Integrity](#import-map-integrity)
4647
- [Runtime Patching](#runtime-patching)
4748
- [Vite Manifest Integration](#vite-manifest-integration)
4849
- [Compatibility](#compatibility)
@@ -107,7 +108,7 @@ type SriPluginOptions = {
107108
crossorigin?: "anonymous" | "use-credentials"; // default: undefined
108109
fetchCache?: boolean; // default: true (in-memory cache + in-flight dedupe for remote assets)
109110
fetchTimeoutMs?: number; // default: 5000 (5 seconds). Abort remote fetches after N ms, 0 to disable timeout
110-
preloadDynamicChunks?: boolean; // default: true. Inject rel="modulepreload" with integrity for discovered lazy chunks
111+
preloadDynamicChunks?: boolean; // default: true. Inject rel="modulepreload" with integrity for discovered lazy chunks. When false, dynamic imports are covered by the injected import map where possible (HTML-only bundle, root-relative/absolute base); JS-level import() rewriting is the fallback for no-HTML, mixed HTML+manifest, or relative-base builds.
111112
runtimePatchDynamicLinks?: boolean; // default: true. Inject a tiny runtime that adds integrity to dynamically created <script>/<link>
112113
skipResources?: string[]; // default: []. Skip SRI for resources matching these patterns (by id or src/href)
113114
verboseLogging?: boolean; // default: false. Show all info-level build logs. When false, only warnings, errors, and a completion summary are shown.
@@ -158,10 +159,31 @@ sri({
158159
159160
### Lazy-loaded chunks and dynamic tags
160161

161-
- If `preloadDynamicChunks` is enabled (default), the plugin scans Rollup output for dynamically imported chunks and injects `<link rel="modulepreload" integrity=...>` for them into emitted HTML, honoring Vite `base` and `crossorigin`. Browser-native SRI then validates each lazy chunk via its preload entry.
162-
- If `preloadDynamicChunks` is disabled (preserving on-demand network behavior for lazy chunks), the plugin instead enforces SRI at the JavaScript layer: every dynamic `import(...)` call site in the bundle is rewritten to a runtime helper that fetches the chunk, verifies its hash against the build-time integrity using `crypto.subtle`, and only then performs the native import. A mismatch throws and aborts module loading. This requires `runtimePatchDynamicLinks` (default on), a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) for `crypto.subtle` (HTTPS or localhost), and matching CORS configuration on the server hosting the chunks. The helper performs a separate verification fetch in addition to the browser's own module fetch — keep `Cache-Control: immutable` (or equivalent) on hashed chunk filenames to avoid a true second network round-trip. Source maps for chunks whose `import()` call sites are rewritten are dropped (the rewrite shifts byte offsets); the original source maps are preserved for any chunk that does not contain a dynamic import.
162+
- Whenever the build emits HTML (and `base` is root-relative or an absolute URL), the plugin injects a `<script type="importmap">` into each HTML file with an [`integrity`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap#integrity_metadata_map) object covering every emitted JS module. Browsers with import map integrity support (Chrome 127+, Firefox 138+, Safari 18+) enforce SRI natively on **both static and dynamic** module imports — including statically imported chunks (such as facade re-exports) that `modulepreload` discovery never scans. Older browsers ignore the `integrity` key (progressive enhancement, the same model as SRI attributes generally). If your HTML already declares an import map, the integrity entries are merged into it; your own entries win on conflict. See [Import Map Integrity](#import-map-integrity).
163+
- If `preloadDynamicChunks` is enabled (default), the plugin additionally scans Rollup output for dynamically imported chunks and injects `<link rel="modulepreload" integrity=...>` for them into emitted HTML, honoring Vite `base` and `crossorigin`. Browser-native SRI then validates each lazy chunk via its preload entry (and preloading changes network timing: lazy chunks are fetched eagerly).
164+
- If `preloadDynamicChunks` is disabled and the import map cannot cover every consumer of the bundle (no HTML in the bundle — e.g. backend-owned HTML consuming `manifest.json` — a manifest emitted **alongside** HTML, or a relative `base` like `'./'`), the plugin falls back to enforcing SRI at the JavaScript layer: every dynamic `import(...)` call site in the bundle is rewritten to a runtime helper that fetches the chunk, verifies its hash against the build-time integrity using `crypto.subtle`, and only then performs the native import. A mismatch throws and aborts module loading. This fallback requires `runtimePatchDynamicLinks` (default on), a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) for `crypto.subtle` (HTTPS or localhost), and matching CORS configuration on the server hosting the chunks. The helper performs a separate verification fetch in addition to the browser's own module fetch — keep `Cache-Control: immutable` (or equivalent) on hashed chunk filenames to avoid a true second network round-trip. Source maps for chunks whose `import()` call sites are rewritten are dropped (the rewrite shifts byte offsets). Builds covered by the import map have none of these constraints.
163165
- If `runtimePatchDynamicLinks` is enabled (default), a tiny runtime is prepended to entry chunks. It sets `integrity` (and `crossorigin` if configured) on dynamically created `<script>` and `<link>` elements for eligible resources (scripts, stylesheets, modulepreload, or preload as=script/style/font) before the network request happens. This is bundled code (not inline) and is CSP-safe.
164166

167+
## Import Map Integrity
168+
169+
When the build emits HTML, SRI for module chunks is declared in an injected import map:
170+
171+
```html
172+
<script type="importmap">
173+
{"integrity":{"/assets/index-B3sb0LQp.js":"sha384-…","/assets/chunk-Cab12xJ4.js":"sha384-…"}}
174+
</script>
175+
```
176+
177+
Supporting browsers (Chrome 127+, Firefox 138+, Safari 18+) apply the integrity metadata to every matching module fetch — static imports, dynamic `import()`, and module preloads alike — and refuse to execute a module whose bytes do not match.
178+
179+
Notes:
180+
181+
- **CSP:** the import map is necessarily an inline script (the HTML spec forbids `src` on import maps). Strict `script-src` policies without `'unsafe-inline'` must allow it via a nonce (server-side templating) or a hash — note the hash changes every build, since the map contains the chunk hashes.
182+
- **Workers:** import maps do not apply inside Web Workers or Service Workers; module chunks loaded there are not covered (the JS-runtime fallback does not cover them either).
183+
- **Relative `base`:** with `base: './'` (or `''`/`'../…'`), import map keys cannot be expressed portably (keys resolve against each document's URL), so injection is skipped and the JS-runtime fallback remains active for `preloadDynamicChunks: false` builds.
184+
- **Older browsers:** Chrome < 127, Firefox < 138, and Safari < 18 parse the map but ignore `integrity` — module loads proceed unverified there, exactly as HTML `integrity` attributes behave on browsers without SRI support. Note that with `runtimePatchDynamicLinks: false` there is no JS-runtime fallback of any kind, so on those older browsers dynamic imports are entirely unverified regardless of the import map.
185+
- **Resources excluded via `skipResources`** are also excluded from the import map, so the opt-out applies to native module-fetch enforcement too.
186+
165187
## Runtime Patching
166188

167189
When `runtimePatchDynamicLinks` is enabled (default), the plugin injects a small runtime into entry chunks that patches DOM manipulation methods to automatically add integrity attributes to dynamically created elements.

src/index.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import type { BundleLogger } from "./internal";
44
import {
55
createLogger,
66
DynamicImportAnalyzer,
7+
escapeForScript,
78
handleGenerateBundleError,
89
HtmlProcessor,
910
installSriRuntime,
1011
IntegrityProcessor,
12+
isImportMapCapableBase,
1113
ManifestProcessor,
1214
validateGenerateBundleInputs,
1315
} from "./internal";
@@ -109,17 +111,11 @@ export function buildSriRuntimeCode(
109111
base?: string;
110112
}
111113
): string {
112-
// `JSON.stringify` does not escape `<` or the U+2028/U+2029 line separators.
113-
// The serialized data is embedded as a JS string literal inside code that is
114-
// prepended to a chunk, so escape those characters to keep the injected
115-
// statement well-formed (and safe if a chunk is ever inlined into HTML).
116-
// This only changes the source representation; the parsed runtime values are
117-
// identical.
118-
const escapeForScript = (json: string): string =>
119-
json
120-
.replace(/</g, "\\u003c")
121-
.replace(/\u2028/g, "\\u2028")
122-
.replace(/\u2029/g, "\\u2029");
114+
// The serialized data is embedded as a JS string literal inside code that
115+
// is prepended to a chunk; escapeForScript (shared with the import map
116+
// injection) keeps the injected statement well-formed and safe if a chunk
117+
// is ever inlined into HTML. This only changes the source representation;
118+
// the parsed runtime values are identical.
123119
const serializedMap = escapeForScript(JSON.stringify(sriByPathname));
124120
const cors = opts.crossorigin ? JSON.stringify(opts.crossorigin) : "false";
125121
const serializedSkipPatterns = escapeForScript(
@@ -250,6 +246,14 @@ export default function sri(options: SriPluginOptions = {}): PluginOption {
250246
if (!hasHtmlFiles && !hasManifestFiles) {
251247
return;
252248
}
249+
// Logged once per build (not per HTML file): with a
250+
// relative base, import map integrity keys cannot be
251+
// expressed portably, so HTML processing skips injection.
252+
if (hasHtmlFiles && !isImportMapCapableBase(base)) {
253+
logger.info(
254+
`Import map SRI skipped: relative base "${base}" cannot produce valid import map keys`
255+
);
256+
}
253257

254258
const integrityProcessor = new IntegrityProcessor(
255259
algorithm,
@@ -268,12 +272,24 @@ export default function sri(options: SriPluginOptions = {}): PluginOption {
268272
// any hashing so the served bytes match the hashed bytes.
269273
// Activation condition: the user has disabled the
270274
// build-time modulepreload injection (preloadDynamicChunks
271-
// is false), which means browser-native SRI on
272-
// modulepreload cannot be relied upon to protect lazy
273-
// chunks. In that case we substitute the global SRI
274-
// verifier injected by the runtime so every dynamic
275-
// import goes through a strict, in-JS integrity check.
276-
const enforceDynamicImports = !preloadDynamicChunks;
275+
// is false) AND the import map cannot cover every consumer
276+
// of this bundle. JS-level enforcement is the fallback
277+
// for builds where the import map is insufficient: no
278+
// HTML in the bundle (backend-owned HTML / manifest
279+
// consumers), a manifest emitted ALONGSIDE HTML (the
280+
// manifest consumer's server-rendered pages have no
281+
// import map, so suppressing the rewrite would silently
282+
// drop their enforcement), or a relative base (no valid
283+
// import map keys). Wherever the import map covers all
284+
// consumers it subsumes this path — the browser enforces
285+
// SRI natively on module fetches (single fetch, no
286+
// rewrite, source maps kept).
287+
const importMapCapable =
288+
hasHtmlFiles &&
289+
!hasManifestFiles &&
290+
isImportMapCapableBase(base);
291+
const enforceDynamicImports =
292+
!preloadDynamicChunks && !importMapCapable;
277293
if (enforceDynamicImports) {
278294
// ORDERING INVARIANT: this rewrite MUST run before
279295
// any hashing AND before the runtime is injected

0 commit comments

Comments
 (0)