vite-plugin-sri4 5.0.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
@@ -1,6 +1,6 @@
1
1
  # vite-plugin-sri4
2
2
 
3
- ![NPM Version](https://img.shields.io/npm/v/vite-plugin-sri4)
3
+ [![NPM Version](https://img.shields.io/npm/v/vite-plugin-sri4)](https://www.npmjs.com/package/vite-plugin-sri4)
4
4
  [![codecov](https://codecov.io/gh/7a6163/vite-plugin-sri4/graph/badge.svg?token=GOVB4J3D19)](https://codecov.io/gh/7a6163/vite-plugin-sri4)
5
5
  ![License](https://img.shields.io/npm/l/vite-plugin-sri4)
6
6
 
@@ -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
 
@@ -180,13 +184,28 @@ A blacklist would have to catch every one of the bottom rows individually, and t
180
184
 
181
185
  **Pin a version in the URL.** `unpkg.com/react@18.3.1/…` answers `max-age=31536000`; `unpkg.com/react@18/…` answers `max-age=60`. Same for jsdelivr. This is the fix, not a workaround — a floating URL and an integrity attribute are contradictory by construction.
182
186
 
183
- **Or vouch for the host** when you know it is stable and it just does not say so:
187
+ **Or vouch for the host** when you know it is stable and it just does not say so. The shape to look for is a URL that already carries a version, served by an origin that simply sends no `Cache-Control` at all:
188
+
189
+ ```
190
+ https://js.tappaysdk.com/sdk/tpdirect/v5.19.2
191
+
192
+ access-control-allow-origin: *
193
+ (no cache-control header)
194
+ ```
195
+
196
+ The version is in the path, so those bytes are as fixed as any `immutable` response — the origin just never says so. That is what `trustDomains` is for:
184
197
 
185
198
  ```js
186
- sri({ trustDomains: ['assets.internal.example'] })
199
+ sri({ trustDomains: ['js.tappaysdk.com'] })
187
200
  ```
188
201
 
189
- Do not point `trustDomains` at a vendor's rolling URL. Stripe, for one, documents that `js.stripe.com/v3/` must not be pinned; forcing a hash onto it produces a page that works until their next deploy.
202
+ **When not to use it.** `trustDomains` overrides the one check that stands between you and a hash that stops matching. Do not point it at:
203
+
204
+ - **a URL without a version in it** — `js.stripe.com/v3/`, `cdn.tailwindcss.com`, `connect.facebook.net/en_US/sdk.js`. Stripe documents that `v3/` must not be pinned; forcing a hash onto it produces a page that works until their next deploy.
205
+ - **a floating range** — `unpkg.com/react@18/…` resolves to whatever 18.x is current.
206
+ - **a host that serves per-client responses** — `fonts.googleapis.com` answers `private` for a reason.
207
+
208
+ The test is not "do I trust this vendor". It is "will these exact bytes still be at this exact URL after their next release". If the answer comes from the URL itself, `trustDomains` is right; if it comes from hope, use `bypassDomains`.
190
209
 
191
210
  **Or accept it and silence the warning** with `bypassDomains`. Third-party analytics and widget scripts are usually this case — they are built to auto-update, and there is nothing to pin:
192
211
 
@@ -232,7 +251,7 @@ There are three places a Vite plugin can compute SRI hashes, and they are not eq
232
251
 
233
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.
234
253
 
235
- 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).
236
255
 
237
256
  ## Example Project
238
257
 
@@ -319,6 +338,20 @@ This will show:
319
338
  - Missing asset warnings
320
339
  - Bundle-key fallback matches (when a URL is resolved via suffix match)
321
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
+
322
355
  ## Contributing
323
356
 
324
357
  We welcome contributions! Here's how you can help:
@@ -335,9 +368,31 @@ Please make sure to:
335
368
  - Follow the existing code style
336
369
  - Update the CHANGELOG.md
337
370
 
338
- ## 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`:
339
374
 
340
- 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
341
396
 
342
397
  Other projects that influenced this work:
343
398
  - [rollup-plugin-sri](https://github.com/JonasKruckenberg/rollup-plugin-sri)
package/dist/index.cjs CHANGED
@@ -49,20 +49,14 @@ class ResourceCache {
49
49
  */
50
50
  class CacheManager {
51
51
  constructor() {
52
- this.urlSupportCache = new ResourceCache();
53
52
  this.resourceCache = new ResourceCache();
54
53
  }
55
54
 
56
- getUrlSupportCache() {
57
- return this.urlSupportCache
58
- }
59
-
60
55
  getResourceCache() {
61
56
  return this.resourceCache
62
57
  }
63
58
 
64
59
  clearAll() {
65
- this.urlSupportCache.clear();
66
60
  this.resourceCache.clear();
67
61
  }
68
62
  }
@@ -75,7 +69,7 @@ const DEFAULT_TIMEOUT = 5000;
75
69
  * Does an external URL's host match one of `domains`, or a subdomain of one?
76
70
  * Used by both `bypassDomains` and `trustDomains`.
77
71
  */
78
- function matchesDomain(url, domains = [], logger = null) {
72
+ function matchesDomain(url, domains = [], logger) {
79
73
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
80
74
  if (domains.length === 0) return false
81
75
 
@@ -85,9 +79,7 @@ function matchesDomain(url, domains = [], logger = null) {
85
79
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
86
80
  )
87
81
  } catch (error) {
88
- if (logger) {
89
- logger.warn(`Invalid URL: ${url}`, error);
90
- }
82
+ logger.warn(`Invalid URL: ${url}`, error);
91
83
  return false
92
84
  }
93
85
  }
@@ -165,35 +157,48 @@ function isImmutableResponse(cacheControl) {
165
157
  /**
166
158
  * Resource check with retry mechanism
167
159
  */
168
- async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
169
- if (urlSupportCache.has(url)) {
170
- return urlSupportCache.get(url)
160
+ /**
161
+ * Fetch an external resource and return its bytes, or null if it must not be
162
+ * hashed. The reason is always logged - a tag that silently ships without
163
+ * integrity is the thing that is easy to miss.
164
+ *
165
+ * One GET, not a HEAD probe followed by a GET. The headers the gates need
166
+ * arrive on the response that carries the bytes anyway, so probing separately
167
+ * doubled the requests and threw the useful copy away - and made the plugin
168
+ * depend on HEAD being served at all. It often is not: js.tappaysdk.com
169
+ * answers 403 to HEAD and 200 to GET, which used to read as "could not be
170
+ * checked" on a payment SDK, exactly the kind of script SRI is for.
171
+ *
172
+ * The cost is that a rejected resource is downloaded before it is rejected.
173
+ * That is the right side to lose on: the accepted case, which is every build
174
+ * that actually ships hashes, goes from two requests to one.
175
+ */
176
+ async function fetchVerifiedResource(url, resourceCache, logger, trusted = false, retries = 1) {
177
+ if (resourceCache.has(url)) {
178
+ return resourceCache.get(url)
171
179
  }
172
180
 
181
+ const reject = (message) => {
182
+ logger.warn(message);
183
+ resourceCache.set(url, null);
184
+ return null
185
+ };
186
+
173
187
  let lastError;
174
188
  for (let attempt = 0; attempt <= retries; attempt++) {
175
189
  try {
176
190
  const controller = new AbortController();
177
191
  const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
178
192
 
179
- const response = await fetch(url, {
180
- method: 'HEAD',
181
- signal: controller.signal
182
- });
193
+ const response = await fetch(url, { signal: controller.signal });
183
194
 
184
195
  clearTimeout(timeoutId);
185
196
 
186
- // Every path out of here that skips a resource says why. A tag that
187
- // silently ships without integrity is the thing that is easy to miss.
188
197
  if (!response.ok) {
189
- if (logger) {
190
- logger.warn(
191
- `Skipping SRI for ${url}: HEAD returned ${response.status}, so the resource ` +
192
- 'could not be checked. Add the domain to bypassDomains to silence this.'
193
- );
194
- }
195
- urlSupportCache.set(url, false);
196
- return false
198
+ return reject(
199
+ `Skipping SRI for ${url}: the server answered ${response.status}. ` +
200
+ 'Add the domain to bypassDomains to silence this.'
201
+ )
197
202
  }
198
203
 
199
204
  // Only `*` can be verified at build time. Injecting integrity also means
@@ -202,16 +207,12 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
202
207
  // served from, that turns a working script into a blocked one.
203
208
  const corsHeader = response.headers.get('access-control-allow-origin');
204
209
  if (corsHeader !== '*') {
205
- if (logger) {
206
- logger.warn(
207
- `Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
208
- `${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
209
- 'cannot be verified at build time. ' +
210
- 'Add the domain to bypassDomains to silence this.'
211
- );
212
- }
213
- urlSupportCache.set(url, false);
214
- return false
210
+ return reject(
211
+ `Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
212
+ `${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
213
+ 'cannot be verified at build time. ' +
214
+ 'Add the domain to bypassDomains to silence this.'
215
+ )
215
216
  }
216
217
 
217
218
  // Reachable and CORS-eligible is not the same property as byte-stable.
@@ -228,65 +229,14 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
228
229
  // so a vary-based gate would let that resource straight through.
229
230
  const cacheControl = response.headers.get('cache-control');
230
231
  if (!trusted && !isImmutableResponse(cacheControl)) {
231
- if (logger) {
232
- logger.warn(
233
- `Skipping SRI for ${url}: Cache-Control is ` +
234
- `${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
235
- 'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
236
- 'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
237
- 'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
238
- "vendor's rolling URL ships a page that breaks on their next deploy."
239
- );
240
- }
241
- urlSupportCache.set(url, false);
242
- return false
243
- }
244
-
245
- urlSupportCache.set(url, true);
246
- return true
247
- } catch (error) {
248
- lastError = error;
249
- if (error.name === 'AbortError') {
250
- if (logger) {
251
- logger.warn(`Resource check timed out: ${url}`);
252
- }
253
- break // Don't retry timeouts
254
- }
255
-
256
- // Don't wait after the last failed attempt
257
- if (attempt < retries) {
258
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
259
- }
260
- }
261
- }
262
-
263
- if (logger) {
264
- logger.warn(`Failed to check resource support: ${url}`, lastError);
265
- }
266
- urlSupportCache.set(url, false);
267
- return false
268
- }
269
-
270
- /**
271
- * Optimized resource fetching function with retry mechanism and caching
272
- */
273
- async function fetchResource(url, resourceCache, logger = null, retries = 1) {
274
- // Check cache
275
- if (resourceCache.has(url)) {
276
- return resourceCache.get(url)
277
- }
278
-
279
- let lastError;
280
- for (let attempt = 0; attempt <= retries; attempt++) {
281
- try {
282
- const controller = new AbortController();
283
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
284
-
285
- const response = await fetch(url, { signal: controller.signal });
286
- clearTimeout(timeoutId);
287
-
288
- if (!response.ok) {
289
- throw new Error(`HTTP error! status: ${response.status}`)
232
+ return reject(
233
+ `Skipping SRI for ${url}: Cache-Control is ` +
234
+ `${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
235
+ 'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
236
+ 'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
237
+ 'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
238
+ "vendor's rolling URL ships a page that breaks on their next deploy."
239
+ )
290
240
  }
291
241
 
292
242
  const data = new Uint8Array(await response.arrayBuffer());
@@ -295,21 +245,18 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
295
245
  } catch (error) {
296
246
  lastError = error;
297
247
  if (error.name === 'AbortError') {
298
- if (logger) {
299
- logger.warn(`Resource fetch timed out: ${url}`);
300
- }
301
- break // Don't retry timeouts
248
+ return reject(`Skipping SRI for ${url}: the request timed out.`)
302
249
  }
303
250
 
251
+ // Don't wait after the last failed attempt
304
252
  if (attempt < retries) {
305
253
  await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
306
254
  }
307
255
  }
308
256
  }
309
257
 
310
- if (logger) {
311
- logger.warn(`Failed to fetch external resource: ${url}`, lastError);
312
- }
258
+ logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
259
+ resourceCache.set(url, null);
313
260
  return null
314
261
  }
315
262
 
@@ -382,14 +329,14 @@ function getBundleKey(htmlPath, url, config) {
382
329
  * `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
383
330
  * but wrong hash, which the browser rejects with no build-time error.
384
331
  */
385
- function findBundleKey(bundle, bundleKey, logger = null) {
332
+ function findBundleKey(bundle, bundleKey, logger) {
386
333
  const candidates = Object.keys(bundle).filter(key =>
387
334
  key === bundleKey ||
388
335
  key.endsWith(`/${bundleKey}`) ||
389
336
  bundleKey.endsWith(`/${key}`)
390
337
  );
391
338
 
392
- if (candidates.length > 1 && logger) {
339
+ if (candidates.length > 1) {
393
340
  logger.warn(
394
341
  `Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
395
342
  );
@@ -423,17 +370,13 @@ async function readPublicAsset(config, bundleKey, logger) {
423
370
  // A URL must never reach outside publicDir, however it is spelled
424
371
  const relative = path.relative(publicDir, filePath);
425
372
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
426
- if (logger) {
427
- logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
428
- }
373
+ logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
429
374
  return null
430
375
  }
431
376
 
432
377
  try {
433
378
  const source = await promises.readFile(filePath);
434
- if (logger) {
435
- logger.debug(`Resolved from publicDir: ${bundleKey}`);
436
- }
379
+ logger.debug(`Resolved from publicDir: ${bundleKey}`);
437
380
  return source
438
381
  } catch {
439
382
  return null
@@ -450,7 +393,7 @@ async function calculateIntegrity(
450
393
  options,
451
394
  config,
452
395
  cacheManager,
453
- logger = null
396
+ logger
454
397
  ) {
455
398
  const {
456
399
  ignoreMissingAsset,
@@ -481,17 +424,13 @@ async function calculateIntegrity(
481
424
  let bundleFileName = null;
482
425
  if (fetchUrl) {
483
426
  const trusted = matchesDomain(fetchUrl, trustDomains, logger);
484
- const isSupported = await checkResourceSupport(
485
- fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
427
+ source = await fetchVerifiedResource(
428
+ fetchUrl, cacheManager.getResourceCache(), logger, trusted
486
429
  );
487
- if (!isSupported) return null
488
- source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
489
430
  if (!source) return null
490
431
  } else if (!ownAsset && SCHEME_RE.test(url)) {
491
432
  // data:/blob: and unknown schemes cannot be resolved to a bundle asset
492
- if (logger) {
493
- logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
494
- }
433
+ logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
495
434
  return null
496
435
  } else {
497
436
  const bundleKey = getBundleKey(htmlPath, url, config);
@@ -504,9 +443,7 @@ async function calculateIntegrity(
504
443
  const possibleMatch = findBundleKey(bundle, bundleKey, logger);
505
444
 
506
445
  if (possibleMatch) {
507
- if (logger) {
508
- logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
509
- }
446
+ logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
510
447
  bundleFileName = possibleMatch;
511
448
  source = bundleSource(bundle[possibleMatch]);
512
449
  } else {
@@ -515,11 +452,9 @@ async function calculateIntegrity(
515
452
 
516
453
  if (!source) {
517
454
  if (ignoreMissingAsset) {
518
- if (logger) {
519
- logger.warn(
520
- `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
521
- );
522
- }
455
+ logger.warn(
456
+ `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
457
+ );
523
458
  return null
524
459
  }
525
460
  throw new Error(
@@ -570,7 +505,9 @@ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
570
505
  function getAttr(tag, re) {
571
506
  const match = tag.match(re);
572
507
  if (!match) return null
573
- 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]
574
511
  }
575
512
 
576
513
  const HTML_PATTERNS = {
@@ -822,9 +759,7 @@ async function transformHTML(
822
759
  function createTransformer(options, config, cacheManager, logger) {
823
760
  return {
824
761
  transformHTML: (bundle, htmlPath, html) =>
825
- transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
826
- calculateIntegrity: (bundle, htmlPath, url) =>
827
- calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
762
+ transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
828
763
  }
829
764
  }
830
765
 
@@ -895,13 +830,6 @@ class Logger {
895
830
  console.debug(...this.formatMessage(message, ...args));
896
831
  }
897
832
  }
898
-
899
- /**
900
- * Create a child logger with the same configuration
901
- */
902
- child(name) {
903
- return new Logger(this.logLevel, `${this.pluginName}:${name}`)
904
- }
905
833
  }
906
834
 
907
835
  // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
package/dist/index.js CHANGED
@@ -45,20 +45,14 @@ class ResourceCache {
45
45
  */
46
46
  class CacheManager {
47
47
  constructor() {
48
- this.urlSupportCache = new ResourceCache();
49
48
  this.resourceCache = new ResourceCache();
50
49
  }
51
50
 
52
- getUrlSupportCache() {
53
- return this.urlSupportCache
54
- }
55
-
56
51
  getResourceCache() {
57
52
  return this.resourceCache
58
53
  }
59
54
 
60
55
  clearAll() {
61
- this.urlSupportCache.clear();
62
56
  this.resourceCache.clear();
63
57
  }
64
58
  }
@@ -71,7 +65,7 @@ const DEFAULT_TIMEOUT = 5000;
71
65
  * Does an external URL's host match one of `domains`, or a subdomain of one?
72
66
  * Used by both `bypassDomains` and `trustDomains`.
73
67
  */
74
- function matchesDomain(url, domains = [], logger = null) {
68
+ function matchesDomain(url, domains = [], logger) {
75
69
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
76
70
  if (domains.length === 0) return false
77
71
 
@@ -81,9 +75,7 @@ function matchesDomain(url, domains = [], logger = null) {
81
75
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
82
76
  )
83
77
  } catch (error) {
84
- if (logger) {
85
- logger.warn(`Invalid URL: ${url}`, error);
86
- }
78
+ logger.warn(`Invalid URL: ${url}`, error);
87
79
  return false
88
80
  }
89
81
  }
@@ -161,35 +153,48 @@ function isImmutableResponse(cacheControl) {
161
153
  /**
162
154
  * Resource check with retry mechanism
163
155
  */
164
- async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
165
- if (urlSupportCache.has(url)) {
166
- return urlSupportCache.get(url)
156
+ /**
157
+ * Fetch an external resource and return its bytes, or null if it must not be
158
+ * hashed. The reason is always logged - a tag that silently ships without
159
+ * integrity is the thing that is easy to miss.
160
+ *
161
+ * One GET, not a HEAD probe followed by a GET. The headers the gates need
162
+ * arrive on the response that carries the bytes anyway, so probing separately
163
+ * doubled the requests and threw the useful copy away - and made the plugin
164
+ * depend on HEAD being served at all. It often is not: js.tappaysdk.com
165
+ * answers 403 to HEAD and 200 to GET, which used to read as "could not be
166
+ * checked" on a payment SDK, exactly the kind of script SRI is for.
167
+ *
168
+ * The cost is that a rejected resource is downloaded before it is rejected.
169
+ * That is the right side to lose on: the accepted case, which is every build
170
+ * that actually ships hashes, goes from two requests to one.
171
+ */
172
+ async function fetchVerifiedResource(url, resourceCache, logger, trusted = false, retries = 1) {
173
+ if (resourceCache.has(url)) {
174
+ return resourceCache.get(url)
167
175
  }
168
176
 
177
+ const reject = (message) => {
178
+ logger.warn(message);
179
+ resourceCache.set(url, null);
180
+ return null
181
+ };
182
+
169
183
  let lastError;
170
184
  for (let attempt = 0; attempt <= retries; attempt++) {
171
185
  try {
172
186
  const controller = new AbortController();
173
187
  const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
174
188
 
175
- const response = await fetch(url, {
176
- method: 'HEAD',
177
- signal: controller.signal
178
- });
189
+ const response = await fetch(url, { signal: controller.signal });
179
190
 
180
191
  clearTimeout(timeoutId);
181
192
 
182
- // Every path out of here that skips a resource says why. A tag that
183
- // silently ships without integrity is the thing that is easy to miss.
184
193
  if (!response.ok) {
185
- if (logger) {
186
- logger.warn(
187
- `Skipping SRI for ${url}: HEAD returned ${response.status}, so the resource ` +
188
- 'could not be checked. Add the domain to bypassDomains to silence this.'
189
- );
190
- }
191
- urlSupportCache.set(url, false);
192
- return false
194
+ return reject(
195
+ `Skipping SRI for ${url}: the server answered ${response.status}. ` +
196
+ 'Add the domain to bypassDomains to silence this.'
197
+ )
193
198
  }
194
199
 
195
200
  // Only `*` can be verified at build time. Injecting integrity also means
@@ -198,16 +203,12 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
198
203
  // served from, that turns a working script into a blocked one.
199
204
  const corsHeader = response.headers.get('access-control-allow-origin');
200
205
  if (corsHeader !== '*') {
201
- if (logger) {
202
- logger.warn(
203
- `Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
204
- `${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
205
- 'cannot be verified at build time. ' +
206
- 'Add the domain to bypassDomains to silence this.'
207
- );
208
- }
209
- urlSupportCache.set(url, false);
210
- return false
206
+ return reject(
207
+ `Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
208
+ `${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
209
+ 'cannot be verified at build time. ' +
210
+ 'Add the domain to bypassDomains to silence this.'
211
+ )
211
212
  }
212
213
 
213
214
  // Reachable and CORS-eligible is not the same property as byte-stable.
@@ -224,65 +225,14 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
224
225
  // so a vary-based gate would let that resource straight through.
225
226
  const cacheControl = response.headers.get('cache-control');
226
227
  if (!trusted && !isImmutableResponse(cacheControl)) {
227
- if (logger) {
228
- logger.warn(
229
- `Skipping SRI for ${url}: Cache-Control is ` +
230
- `${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
231
- 'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
232
- 'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
233
- 'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
234
- "vendor's rolling URL ships a page that breaks on their next deploy."
235
- );
236
- }
237
- urlSupportCache.set(url, false);
238
- return false
239
- }
240
-
241
- urlSupportCache.set(url, true);
242
- return true
243
- } catch (error) {
244
- lastError = error;
245
- if (error.name === 'AbortError') {
246
- if (logger) {
247
- logger.warn(`Resource check timed out: ${url}`);
248
- }
249
- break // Don't retry timeouts
250
- }
251
-
252
- // Don't wait after the last failed attempt
253
- if (attempt < retries) {
254
- await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
255
- }
256
- }
257
- }
258
-
259
- if (logger) {
260
- logger.warn(`Failed to check resource support: ${url}`, lastError);
261
- }
262
- urlSupportCache.set(url, false);
263
- return false
264
- }
265
-
266
- /**
267
- * Optimized resource fetching function with retry mechanism and caching
268
- */
269
- async function fetchResource(url, resourceCache, logger = null, retries = 1) {
270
- // Check cache
271
- if (resourceCache.has(url)) {
272
- return resourceCache.get(url)
273
- }
274
-
275
- let lastError;
276
- for (let attempt = 0; attempt <= retries; attempt++) {
277
- try {
278
- const controller = new AbortController();
279
- const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
280
-
281
- const response = await fetch(url, { signal: controller.signal });
282
- clearTimeout(timeoutId);
283
-
284
- if (!response.ok) {
285
- throw new Error(`HTTP error! status: ${response.status}`)
228
+ return reject(
229
+ `Skipping SRI for ${url}: Cache-Control is ` +
230
+ `${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
231
+ 'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
232
+ 'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
233
+ 'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
234
+ "vendor's rolling URL ships a page that breaks on their next deploy."
235
+ )
286
236
  }
287
237
 
288
238
  const data = new Uint8Array(await response.arrayBuffer());
@@ -291,21 +241,18 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
291
241
  } catch (error) {
292
242
  lastError = error;
293
243
  if (error.name === 'AbortError') {
294
- if (logger) {
295
- logger.warn(`Resource fetch timed out: ${url}`);
296
- }
297
- break // Don't retry timeouts
244
+ return reject(`Skipping SRI for ${url}: the request timed out.`)
298
245
  }
299
246
 
247
+ // Don't wait after the last failed attempt
300
248
  if (attempt < retries) {
301
249
  await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
302
250
  }
303
251
  }
304
252
  }
305
253
 
306
- if (logger) {
307
- logger.warn(`Failed to fetch external resource: ${url}`, lastError);
308
- }
254
+ logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
255
+ resourceCache.set(url, null);
309
256
  return null
310
257
  }
311
258
 
@@ -378,14 +325,14 @@ function getBundleKey(htmlPath, url, config) {
378
325
  * `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
379
326
  * but wrong hash, which the browser rejects with no build-time error.
380
327
  */
381
- function findBundleKey(bundle, bundleKey, logger = null) {
328
+ function findBundleKey(bundle, bundleKey, logger) {
382
329
  const candidates = Object.keys(bundle).filter(key =>
383
330
  key === bundleKey ||
384
331
  key.endsWith(`/${bundleKey}`) ||
385
332
  bundleKey.endsWith(`/${key}`)
386
333
  );
387
334
 
388
- if (candidates.length > 1 && logger) {
335
+ if (candidates.length > 1) {
389
336
  logger.warn(
390
337
  `Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
391
338
  );
@@ -419,17 +366,13 @@ async function readPublicAsset(config, bundleKey, logger) {
419
366
  // A URL must never reach outside publicDir, however it is spelled
420
367
  const relative = path.relative(publicDir, filePath);
421
368
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
422
- if (logger) {
423
- logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
424
- }
369
+ logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
425
370
  return null
426
371
  }
427
372
 
428
373
  try {
429
374
  const source = await readFile(filePath);
430
- if (logger) {
431
- logger.debug(`Resolved from publicDir: ${bundleKey}`);
432
- }
375
+ logger.debug(`Resolved from publicDir: ${bundleKey}`);
433
376
  return source
434
377
  } catch {
435
378
  return null
@@ -446,7 +389,7 @@ async function calculateIntegrity(
446
389
  options,
447
390
  config,
448
391
  cacheManager,
449
- logger = null
392
+ logger
450
393
  ) {
451
394
  const {
452
395
  ignoreMissingAsset,
@@ -477,17 +420,13 @@ async function calculateIntegrity(
477
420
  let bundleFileName = null;
478
421
  if (fetchUrl) {
479
422
  const trusted = matchesDomain(fetchUrl, trustDomains, logger);
480
- const isSupported = await checkResourceSupport(
481
- fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
423
+ source = await fetchVerifiedResource(
424
+ fetchUrl, cacheManager.getResourceCache(), logger, trusted
482
425
  );
483
- if (!isSupported) return null
484
- source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
485
426
  if (!source) return null
486
427
  } else if (!ownAsset && SCHEME_RE.test(url)) {
487
428
  // data:/blob: and unknown schemes cannot be resolved to a bundle asset
488
- if (logger) {
489
- logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
490
- }
429
+ logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
491
430
  return null
492
431
  } else {
493
432
  const bundleKey = getBundleKey(htmlPath, url, config);
@@ -500,9 +439,7 @@ async function calculateIntegrity(
500
439
  const possibleMatch = findBundleKey(bundle, bundleKey, logger);
501
440
 
502
441
  if (possibleMatch) {
503
- if (logger) {
504
- logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
505
- }
442
+ logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
506
443
  bundleFileName = possibleMatch;
507
444
  source = bundleSource(bundle[possibleMatch]);
508
445
  } else {
@@ -511,11 +448,9 @@ async function calculateIntegrity(
511
448
 
512
449
  if (!source) {
513
450
  if (ignoreMissingAsset) {
514
- if (logger) {
515
- logger.warn(
516
- `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
517
- );
518
- }
451
+ logger.warn(
452
+ `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
453
+ );
519
454
  return null
520
455
  }
521
456
  throw new Error(
@@ -566,7 +501,9 @@ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
566
501
  function getAttr(tag, re) {
567
502
  const match = tag.match(re);
568
503
  if (!match) return null
569
- 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]
570
507
  }
571
508
 
572
509
  const HTML_PATTERNS = {
@@ -818,9 +755,7 @@ async function transformHTML(
818
755
  function createTransformer(options, config, cacheManager, logger) {
819
756
  return {
820
757
  transformHTML: (bundle, htmlPath, html) =>
821
- transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
822
- calculateIntegrity: (bundle, htmlPath, url) =>
823
- calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
758
+ transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
824
759
  }
825
760
  }
826
761
 
@@ -891,13 +826,6 @@ class Logger {
891
826
  console.debug(...this.formatMessage(message, ...args));
892
827
  }
893
828
  }
894
-
895
- /**
896
- * Create a child logger with the same configuration
897
- */
898
- child(name) {
899
- return new Logger(this.logLevel, `${this.pluginName}:${name}`)
900
- }
901
829
  }
902
830
 
903
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.0.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",