vite-plugin-sri4 5.1.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,9 +16,11 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
16
16
  - [External Resources](#external-resources)
17
17
  - [When SRI Actually Helps](#when-sri-actually-helps)
18
18
  - [How It Attaches Hashes](#how-it-attaches-hashes)
19
+ - [Differences from vite-plugin-sri3](#differences-from-vite-plugin-sri3)
19
20
  - [Example Project](#example-project)
20
21
  - [Best Practices](#best-practices)
21
22
  - [Troubleshooting](#troubleshooting)
23
+ - [Testing](#testing)
22
24
  - [Contributing](#contributing)
23
25
  - [Inspiration](#inspiration)
24
26
  - [License](#license)
@@ -144,9 +146,11 @@ The `writeBundle` drift check above covers **your own build outputs only**. Ever
144
146
 
145
147
  An external URL pointing at someone else's origin is different. It is fetched **once, at build time, from your build machine**, and the hash is taken from that copy. An `integrity` attribute pins those bytes forever, so it is only correct on a URL whose bytes never change — and the origin is the only party that knows whether that is true.
146
148
 
147
- So the plugin asks it. An external resource is hashed only when **all three** hold:
149
+ So the plugin asks it. One `GET` does the whole job — it carries both the bytes to hash and the headers the answer depends on. There is no separate `HEAD` probe, so a host that serves `GET` and refuses `HEAD` is not a problem; `js.tappaysdk.com` answers `403` to `HEAD` and `200` to `GET`, and is read correctly.
148
150
 
149
- 1. **`HEAD` succeeds.** Otherwise there is nothing to check.
151
+ An external resource is hashed only when **all three** hold:
152
+
153
+ 1. **The request succeeds.** A non-2xx response leaves nothing to check.
150
154
  2. **`Access-Control-Allow-Origin: *`.** Injecting `integrity` also injects `crossorigin`, so a response scoped to one specific origin — or to none — would turn a working resource into a blocked one.
151
155
  3. **The origin declares the URL immutable**: `Cache-Control: immutable`, or a `max-age` of a year or more — and nothing in the same header contradicting it. `private`, `no-store` and `no-cache` each veto it: freshness and shareability are orthogonal, so a per-client response can carry a long `max-age`, and `no-cache, max-age=<long>` is a real CDN spelling of "cache it, but revalidate every time". Or the host is in `trustDomains`.
152
156
 
@@ -247,7 +251,7 @@ There are three places a Vite plugin can compute SRI hashes, and they are not eq
247
251
 
248
252
  Only the entry chunk is affected, so a build without a dynamic import will not reveal the difference. As a second safeguard, every hashed file is re-hashed in `writeBundle` and the build fails if anything changed after the hash was taken.
249
253
 
250
- This ordering constraint was first identified by [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3), which this plugin began as a fork of. Beyond it, this plugin adds `crossorigin` injection, a CORS pre-check with timeouts and retries for external resources, import map and manifest output for dynamically imported routes, `publicDir` resolution, and the drift check above.
254
+ This ordering constraint was first identified by [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3), which this plugin began as a fork of. See [Differences from vite-plugin-sri3](#differences-from-vite-plugin-sri3).
251
255
 
252
256
  ## Example Project
253
257
 
@@ -334,6 +338,20 @@ This will show:
334
338
  - Missing asset warnings
335
339
  - Bundle-key fallback matches (when a URL is resolved via suffix match)
336
340
 
341
+ ## Testing
342
+
343
+ ```bash
344
+ npm test # 174 tests
345
+ npm run test:coverage # the same, with coverage thresholds enforced at 100%
346
+ npm run test:mutation # Stryker, ~4 minutes
347
+ ```
348
+
349
+ Coverage is held at **100%** of statements, branches, functions and lines, enforced by thresholds in `vitest.config.js` — an uncovered path fails the run rather than quietly lowering the number.
350
+
351
+ Coverage only proves a line ran. Mutation testing changes the code and checks whether a test notices, which is a much harder bar: at 100% coverage this suite still let 162 mutants through on the first run. The gaps it found were real — `no-store` and the qualified `private="…"` / `no-cache="…"` forms were never exercised, the `publicDir` path-traversal guard had no test, `base: './'` and `base: ''` were unreachable from any test, and the immutability tests all used `max-age=31536000, immutable`, where **both** passing conditions hold at once, so neither was actually pinned.
352
+
353
+ The mutation score is **81.19%**, with a break threshold of 80. The remaining survivors are mostly equivalent mutants — warning message wording, cache clearing that has no observable effect, and `typeof source === 'string' ? source : Buffer.from(source)`, whose two branches hash identically. Killing those would mean asserting log text verbatim, which costs more than it protects.
354
+
337
355
  ## Contributing
338
356
 
339
357
  We welcome contributions! Here's how you can help:
@@ -350,9 +368,31 @@ Please make sure to:
350
368
  - Follow the existing code style
351
369
  - Update the CHANGELOG.md
352
370
 
353
- ## Inspiration
371
+ ## Differences from vite-plugin-sri3
372
+
373
+ This plugin began as a fork of [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3) and the two have since diverged. Compared against sri3 `2.0.0`:
354
374
 
355
- This project was inspired by [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3), which provides subresource integrity for Vite. We've built upon its foundation to create an enhanced version with additional features and improved compatibility.
375
+ | | sri3 2.0.0 | sri4 5.1.0 |
376
+ |---|---|---|
377
+ | Vite range | `^3 ‖ ^4 ‖ ^5 ‖ ^6 ‖ ^7 ‖ ^8` | `^6.4 ‖ ^7 ‖ ^8` |
378
+ | Bundle outputs | ✅ | ✅ |
379
+ | `publicDir` assets | ✅ | ✅ |
380
+ | `skip-sri` per-tag opt-out | ✅ | ✅ |
381
+ | TypeScript definitions | ✅ | ✅ |
382
+ | Hash algorithm | `sha384`, fixed | `sha256` / `sha384` / `sha512`, validated at startup |
383
+ | `crossorigin` attribute | not injected | injected, `anonymous` or `use-credentials` |
384
+ | External resources | fetched unconditionally | gated on reachability, CORS and immutability |
385
+ | Timeout / retry / cache on those fetches | ❌ | ✅ |
386
+ | `bypassDomains` / `trustDomains` | ❌ | ✅ |
387
+ | Hash drift detection | ❌ | re-hashed in `writeBundle`, build fails on drift |
388
+ | `import()`-loaded routes, SSR | ❌ | `importmap` and `manifest` options |
389
+ | Hook ordering | monkey-patches Vite's `generateBundle` | repositions itself in `config.plugins` |
390
+
391
+ **Where sri3 is the better fit:** it supports Vite 3 through 5, which this plugin dropped. If you are on an older Vite, it is the only one of the two that works.
392
+
393
+ **The difference that matters most:** sri3 injects `integrity` without `crossorigin`. SRI on a cross-origin resource requires CORS, so a browser blocks a cross-origin `<script>` or `<link>` that carries `integrity` and no `crossorigin` — which makes sri3's external-resource support difficult to use for the case SRI is usually reached for. That gap is what most of the column above grew out of: injecting `crossorigin` means the CORS response has to be checked at build time, and checking it exposed everything else worth checking.
394
+
395
+ ## Inspiration
356
396
 
357
397
  Other projects that influenced this work:
358
398
  - [rollup-plugin-sri](https://github.com/JonasKruckenberg/rollup-plugin-sri)
package/dist/index.cjs CHANGED
@@ -69,7 +69,7 @@ const DEFAULT_TIMEOUT = 5000;
69
69
  * Does an external URL's host match one of `domains`, or a subdomain of one?
70
70
  * Used by both `bypassDomains` and `trustDomains`.
71
71
  */
72
- function matchesDomain(url, domains = [], logger = null) {
72
+ function matchesDomain(url, domains = [], logger) {
73
73
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
74
74
  if (domains.length === 0) return false
75
75
 
@@ -79,9 +79,7 @@ function matchesDomain(url, domains = [], logger = null) {
79
79
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
80
80
  )
81
81
  } catch (error) {
82
- if (logger) {
83
- logger.warn(`Invalid URL: ${url}`, error);
84
- }
82
+ logger.warn(`Invalid URL: ${url}`, error);
85
83
  return false
86
84
  }
87
85
  }
@@ -175,13 +173,13 @@ function isImmutableResponse(cacheControl) {
175
173
  * That is the right side to lose on: the accepted case, which is every build
176
174
  * that actually ships hashes, goes from two requests to one.
177
175
  */
178
- async function fetchVerifiedResource(url, resourceCache, logger = null, trusted = false, retries = 1) {
176
+ async function fetchVerifiedResource(url, resourceCache, logger, trusted = false, retries = 1) {
179
177
  if (resourceCache.has(url)) {
180
178
  return resourceCache.get(url)
181
179
  }
182
180
 
183
181
  const reject = (message) => {
184
- if (logger && message) logger.warn(message);
182
+ logger.warn(message);
185
183
  resourceCache.set(url, null);
186
184
  return null
187
185
  };
@@ -257,9 +255,7 @@ async function fetchVerifiedResource(url, resourceCache, logger = null, trusted
257
255
  }
258
256
  }
259
257
 
260
- if (logger) {
261
- logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
262
- }
258
+ logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
263
259
  resourceCache.set(url, null);
264
260
  return null
265
261
  }
@@ -333,14 +329,14 @@ function getBundleKey(htmlPath, url, config) {
333
329
  * `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
334
330
  * but wrong hash, which the browser rejects with no build-time error.
335
331
  */
336
- function findBundleKey(bundle, bundleKey, logger = null) {
332
+ function findBundleKey(bundle, bundleKey, logger) {
337
333
  const candidates = Object.keys(bundle).filter(key =>
338
334
  key === bundleKey ||
339
335
  key.endsWith(`/${bundleKey}`) ||
340
336
  bundleKey.endsWith(`/${key}`)
341
337
  );
342
338
 
343
- if (candidates.length > 1 && logger) {
339
+ if (candidates.length > 1) {
344
340
  logger.warn(
345
341
  `Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
346
342
  );
@@ -374,17 +370,13 @@ async function readPublicAsset(config, bundleKey, logger) {
374
370
  // A URL must never reach outside publicDir, however it is spelled
375
371
  const relative = path.relative(publicDir, filePath);
376
372
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
377
- if (logger) {
378
- logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
379
- }
373
+ logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
380
374
  return null
381
375
  }
382
376
 
383
377
  try {
384
378
  const source = await promises.readFile(filePath);
385
- if (logger) {
386
- logger.debug(`Resolved from publicDir: ${bundleKey}`);
387
- }
379
+ logger.debug(`Resolved from publicDir: ${bundleKey}`);
388
380
  return source
389
381
  } catch {
390
382
  return null
@@ -401,7 +393,7 @@ async function calculateIntegrity(
401
393
  options,
402
394
  config,
403
395
  cacheManager,
404
- logger = null
396
+ logger
405
397
  ) {
406
398
  const {
407
399
  ignoreMissingAsset,
@@ -438,9 +430,7 @@ async function calculateIntegrity(
438
430
  if (!source) return null
439
431
  } else if (!ownAsset && SCHEME_RE.test(url)) {
440
432
  // data:/blob: and unknown schemes cannot be resolved to a bundle asset
441
- if (logger) {
442
- logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
443
- }
433
+ logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
444
434
  return null
445
435
  } else {
446
436
  const bundleKey = getBundleKey(htmlPath, url, config);
@@ -453,9 +443,7 @@ async function calculateIntegrity(
453
443
  const possibleMatch = findBundleKey(bundle, bundleKey, logger);
454
444
 
455
445
  if (possibleMatch) {
456
- if (logger) {
457
- logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
458
- }
446
+ logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
459
447
  bundleFileName = possibleMatch;
460
448
  source = bundleSource(bundle[possibleMatch]);
461
449
  } else {
@@ -464,11 +452,9 @@ async function calculateIntegrity(
464
452
 
465
453
  if (!source) {
466
454
  if (ignoreMissingAsset) {
467
- if (logger) {
468
- logger.warn(
469
- `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
470
- );
471
- }
455
+ logger.warn(
456
+ `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
457
+ );
472
458
  return null
473
459
  }
474
460
  throw new Error(
@@ -519,7 +505,9 @@ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
519
505
  function getAttr(tag, re) {
520
506
  const match = tag.match(re);
521
507
  if (!match) return null
522
- return match[1] ?? match[2] ?? match[3] ?? null
508
+ // One of the three alternatives matched or the regex would not have, and an
509
+ // empty value is a string rather than undefined - so there is no fourth case.
510
+ return match[1] ?? match[2] ?? match[3]
523
511
  }
524
512
 
525
513
  const HTML_PATTERNS = {
@@ -771,9 +759,7 @@ async function transformHTML(
771
759
  function createTransformer(options, config, cacheManager, logger) {
772
760
  return {
773
761
  transformHTML: (bundle, htmlPath, html) =>
774
- transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
775
- calculateIntegrity: (bundle, htmlPath, url) =>
776
- calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
762
+ transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
777
763
  }
778
764
  }
779
765
 
@@ -844,13 +830,6 @@ class Logger {
844
830
  console.debug(...this.formatMessage(message, ...args));
845
831
  }
846
832
  }
847
-
848
- /**
849
- * Create a child logger with the same configuration
850
- */
851
- child(name) {
852
- return new Logger(this.logLevel, `${this.pluginName}:${name}`)
853
- }
854
833
  }
855
834
 
856
835
  // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ const DEFAULT_TIMEOUT = 5000;
65
65
  * Does an external URL's host match one of `domains`, or a subdomain of one?
66
66
  * Used by both `bypassDomains` and `trustDomains`.
67
67
  */
68
- function matchesDomain(url, domains = [], logger = null) {
68
+ function matchesDomain(url, domains = [], logger) {
69
69
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
70
70
  if (domains.length === 0) return false
71
71
 
@@ -75,9 +75,7 @@ function matchesDomain(url, domains = [], logger = null) {
75
75
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
76
76
  )
77
77
  } catch (error) {
78
- if (logger) {
79
- logger.warn(`Invalid URL: ${url}`, error);
80
- }
78
+ logger.warn(`Invalid URL: ${url}`, error);
81
79
  return false
82
80
  }
83
81
  }
@@ -171,13 +169,13 @@ function isImmutableResponse(cacheControl) {
171
169
  * That is the right side to lose on: the accepted case, which is every build
172
170
  * that actually ships hashes, goes from two requests to one.
173
171
  */
174
- async function fetchVerifiedResource(url, resourceCache, logger = null, trusted = false, retries = 1) {
172
+ async function fetchVerifiedResource(url, resourceCache, logger, trusted = false, retries = 1) {
175
173
  if (resourceCache.has(url)) {
176
174
  return resourceCache.get(url)
177
175
  }
178
176
 
179
177
  const reject = (message) => {
180
- if (logger && message) logger.warn(message);
178
+ logger.warn(message);
181
179
  resourceCache.set(url, null);
182
180
  return null
183
181
  };
@@ -253,9 +251,7 @@ async function fetchVerifiedResource(url, resourceCache, logger = null, trusted
253
251
  }
254
252
  }
255
253
 
256
- if (logger) {
257
- logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
258
- }
254
+ logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
259
255
  resourceCache.set(url, null);
260
256
  return null
261
257
  }
@@ -329,14 +325,14 @@ function getBundleKey(htmlPath, url, config) {
329
325
  * `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
330
326
  * but wrong hash, which the browser rejects with no build-time error.
331
327
  */
332
- function findBundleKey(bundle, bundleKey, logger = null) {
328
+ function findBundleKey(bundle, bundleKey, logger) {
333
329
  const candidates = Object.keys(bundle).filter(key =>
334
330
  key === bundleKey ||
335
331
  key.endsWith(`/${bundleKey}`) ||
336
332
  bundleKey.endsWith(`/${key}`)
337
333
  );
338
334
 
339
- if (candidates.length > 1 && logger) {
335
+ if (candidates.length > 1) {
340
336
  logger.warn(
341
337
  `Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
342
338
  );
@@ -370,17 +366,13 @@ async function readPublicAsset(config, bundleKey, logger) {
370
366
  // A URL must never reach outside publicDir, however it is spelled
371
367
  const relative = path.relative(publicDir, filePath);
372
368
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
373
- if (logger) {
374
- logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
375
- }
369
+ logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
376
370
  return null
377
371
  }
378
372
 
379
373
  try {
380
374
  const source = await readFile(filePath);
381
- if (logger) {
382
- logger.debug(`Resolved from publicDir: ${bundleKey}`);
383
- }
375
+ logger.debug(`Resolved from publicDir: ${bundleKey}`);
384
376
  return source
385
377
  } catch {
386
378
  return null
@@ -397,7 +389,7 @@ async function calculateIntegrity(
397
389
  options,
398
390
  config,
399
391
  cacheManager,
400
- logger = null
392
+ logger
401
393
  ) {
402
394
  const {
403
395
  ignoreMissingAsset,
@@ -434,9 +426,7 @@ async function calculateIntegrity(
434
426
  if (!source) return null
435
427
  } else if (!ownAsset && SCHEME_RE.test(url)) {
436
428
  // data:/blob: and unknown schemes cannot be resolved to a bundle asset
437
- if (logger) {
438
- logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
439
- }
429
+ logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
440
430
  return null
441
431
  } else {
442
432
  const bundleKey = getBundleKey(htmlPath, url, config);
@@ -449,9 +439,7 @@ async function calculateIntegrity(
449
439
  const possibleMatch = findBundleKey(bundle, bundleKey, logger);
450
440
 
451
441
  if (possibleMatch) {
452
- if (logger) {
453
- logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
454
- }
442
+ logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
455
443
  bundleFileName = possibleMatch;
456
444
  source = bundleSource(bundle[possibleMatch]);
457
445
  } else {
@@ -460,11 +448,9 @@ async function calculateIntegrity(
460
448
 
461
449
  if (!source) {
462
450
  if (ignoreMissingAsset) {
463
- if (logger) {
464
- logger.warn(
465
- `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
466
- );
467
- }
451
+ logger.warn(
452
+ `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
453
+ );
468
454
  return null
469
455
  }
470
456
  throw new Error(
@@ -515,7 +501,9 @@ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
515
501
  function getAttr(tag, re) {
516
502
  const match = tag.match(re);
517
503
  if (!match) return null
518
- return match[1] ?? match[2] ?? match[3] ?? null
504
+ // One of the three alternatives matched or the regex would not have, and an
505
+ // empty value is a string rather than undefined - so there is no fourth case.
506
+ return match[1] ?? match[2] ?? match[3]
519
507
  }
520
508
 
521
509
  const HTML_PATTERNS = {
@@ -767,9 +755,7 @@ async function transformHTML(
767
755
  function createTransformer(options, config, cacheManager, logger) {
768
756
  return {
769
757
  transformHTML: (bundle, htmlPath, html) =>
770
- transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
771
- calculateIntegrity: (bundle, htmlPath, url) =>
772
- calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
758
+ transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
773
759
  }
774
760
  }
775
761
 
@@ -840,13 +826,6 @@ class Logger {
840
826
  console.debug(...this.formatMessage(message, ...args));
841
827
  }
842
828
  }
843
-
844
- /**
845
- * Create a child logger with the same configuration
846
- */
847
- child(name) {
848
- return new Logger(this.logLevel, `${this.pluginName}:${name}`)
849
- }
850
829
  }
851
830
 
852
831
  // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-sri4",
3
- "version": "5.1.0",
3
+ "version": "5.1.1",
4
4
  "description": "A Vite plugin to generate Subresource Integrity (SRI) hashes for output files.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -24,7 +24,8 @@
24
24
  "test": "vitest run",
25
25
  "test:watch": "vitest",
26
26
  "test:coverage": "vitest run --coverage",
27
- "prepublishOnly": "npm run build"
27
+ "prepublishOnly": "npm run build",
28
+ "test:mutation": "stryker run"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
@@ -32,6 +33,8 @@
32
33
  "devDependencies": {
33
34
  "@rollup/plugin-commonjs": "^29.0.3",
34
35
  "@rollup/plugin-node-resolve": "^16.0.3",
36
+ "@stryker-mutator/core": "^10.0.0",
37
+ "@stryker-mutator/vitest-runner": "^10.0.0",
35
38
  "@vitest/coverage-v8": "^5.0.0",
36
39
  "oxlint": "^1.82.0",
37
40
  "rollup": "^4.63.1",