vite-plugin-sri4 4.1.0 → 5.0.0

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/dist/index.cjs CHANGED
@@ -3,8 +3,8 @@
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var node_crypto = require('node:crypto');
6
+ var promises = require('node:fs/promises');
6
7
  var path = require('node:path');
7
- var fetch = require('cross-fetch');
8
8
 
9
9
  /**
10
10
  * Extended caching mechanism with expiration time
@@ -67,17 +67,21 @@ class CacheManager {
67
67
  }
68
68
  }
69
69
 
70
+ // Global fetch, stable since Node 18 - the floor the Vite 6.4 peer range
71
+ // already implies. No dependency needed.
70
72
  const DEFAULT_TIMEOUT = 5000;
71
73
 
72
74
  /**
73
- * Check if URL is from a bypass domain
75
+ * Does an external URL's host match one of `domains`, or a subdomain of one?
76
+ * Used by both `bypassDomains` and `trustDomains`.
74
77
  */
75
- function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
78
+ function matchesDomain(url, domains = [], logger = null) {
76
79
  if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
80
+ if (domains.length === 0) return false
77
81
 
78
82
  try {
79
83
  const urlObj = new URL(url);
80
- return bypassDomains.some(domain =>
84
+ return domains.some(domain =>
81
85
  urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
82
86
  )
83
87
  } catch (error) {
@@ -88,10 +92,80 @@ function isUrlFromBypassDomain(url, bypassDomains = [], logger = null) {
88
92
  }
89
93
  }
90
94
 
95
+ // A year. The conventional encoding of "this URL's bytes will never change",
96
+ // and what every CDN puts on a version-pinned path.
97
+ const IMMUTABLE_MAX_AGE = 31536000;
98
+
99
+ /**
100
+ * Does the origin declare this URL's bytes immutable - `Cache-Control:
101
+ * immutable`, or a max-age of a year or more - and nothing in the same header
102
+ * contradicting it?
103
+ *
104
+ * Measured, because the split is what makes this usable as a gate. Pinned
105
+ * third-party libraries, the case SRI actually exists for:
106
+ *
107
+ * cdnjs jquery/3.7.1 max-age=30672000, immutable
108
+ * jsdelivr bootstrap@5.3.3 max-age=31536000, immutable
109
+ * unpkg htmx.org@1.9.12 max-age=31536000
110
+ * code.jquery.com 3.7.1 max-age=31536000
111
+ *
112
+ * Everything that rolls under a stable URL:
113
+ *
114
+ * jsdelivr vue@3 max-age=604800
115
+ * fonts.googleapis.com max-age=86400 (also `private`)
116
+ * plausible.io/js/script.js max-age=86400
117
+ * cdn.tailwindcss.com max-age=14400
118
+ * connect.facebook.net max-age=1200
119
+ * js.stripe.com/v3/ max-age=120
120
+ * unpkg react@18 max-age=60
121
+ *
122
+ * Nothing lands between 604800 and 30672000, so the threshold is not a
123
+ * balancing act - it separates two clusters the CDNs themselves created.
124
+ */
125
+ function isImmutableResponse(cacheControl) {
126
+ if (!cacheControl) return false
127
+
128
+ let immutable = false;
129
+
130
+ for (const directive of cacheControl.split(',')) {
131
+ // Token equality, not substring: `x-immutable` is not this directive
132
+ const token = directive.trim().toLowerCase();
133
+
134
+ // These veto whatever else the header claims, and are checked against the
135
+ // whole header rather than returning early, because freshness and
136
+ // shareability are orthogonal - a per-client response can carry a long
137
+ // max-age, and `no-cache, max-age=<long>` is a real CDN spelling of "cache
138
+ // it, but revalidate every time", i.e. the bytes may have changed.
139
+ //
140
+ // The qualified forms (`private="set-cookie"`, `no-cache="set-cookie"`)
141
+ // only scope the directive to those headers, so vetoing on them is
142
+ // stricter than the spec requires. That is the right way to be wrong here:
143
+ // the cost is losing SRI on a resource that would have been fine, and it
144
+ // is logged. Not vetoing costs a page that only breaks in the browser.
145
+ if (
146
+ token === 'no-store' ||
147
+ token === 'private' || token.startsWith('private=') ||
148
+ token === 'no-cache' || token.startsWith('no-cache=')
149
+ ) {
150
+ return false
151
+ }
152
+
153
+ if (token === 'immutable') immutable = true;
154
+
155
+ if (token.startsWith('max-age=')) {
156
+ // RFC 9111 permits a quoted-string value: `max-age="31536000"`
157
+ const seconds = Number(token.slice('max-age='.length).replace(/^"|"$/g, ''));
158
+ if (Number.isFinite(seconds) && seconds >= IMMUTABLE_MAX_AGE) immutable = true;
159
+ }
160
+ }
161
+
162
+ return immutable
163
+ }
164
+
91
165
  /**
92
166
  * Resource check with retry mechanism
93
167
  */
94
- async function checkResourceSupport(url, urlSupportCache, logger = null, retries = 2) {
168
+ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted = false, retries = 2) {
95
169
  if (urlSupportCache.has(url)) {
96
170
  return urlSupportCache.get(url)
97
171
  }
@@ -109,23 +183,67 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
109
183
 
110
184
  clearTimeout(timeoutId);
111
185
 
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
+ 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
197
+ }
198
+
112
199
  // Only `*` can be verified at build time. Injecting integrity also means
113
200
  // injecting crossorigin="anonymous"; if the server answers with a
114
201
  // concrete origin that does not match wherever the HTML ends up being
115
- // served from, that turns a working script into a blocked one. Skipping
116
- // is the safe outcome, but say so at warn level - silence here is what
117
- // makes an unprotected resource easy to miss.
202
+ // served from, that turns a working script into a blocked one.
118
203
  const corsHeader = response.headers.get('access-control-allow-origin');
119
- const isSupported = response.ok && corsHeader === '*';
120
- if (response.ok && corsHeader && corsHeader !== '*' && logger) {
121
- logger.warn(
122
- `Skipping SRI for ${url}: Access-Control-Allow-Origin is "${corsHeader}", not "*", ` +
123
- 'so crossorigin="anonymous" cannot be verified at build time. ' +
124
- 'Add the domain to bypassDomains to silence this.'
125
- );
204
+ 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
126
215
  }
127
- urlSupportCache.set(url, isSupported);
128
- return isSupported
216
+
217
+ // Reachable and CORS-eligible is not the same property as byte-stable.
218
+ // A hash pins one snapshot of bytes forever, so it is only safe on a URL
219
+ // whose bytes never change - and the origin is the only party that knows.
220
+ // Require it to say so rather than hunting for reasons to skip: a
221
+ // blacklist of known-bad origins is never finished (Google Fonts is
222
+ // `private`, but cdn.tailwindcss.com and plausible.io are ordinary
223
+ // `public` responses that roll just the same), and every gap in it ships
224
+ // a build that works today and breaks whenever the vendor deploys.
225
+ //
226
+ // Deliberately not `vary`: Google Fonts varies on User-Agent without
227
+ // declaring it (`vary: Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site`),
228
+ // so a vary-based gate would let that resource straight through.
229
+ const cacheControl = response.headers.get('cache-control');
230
+ 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
129
247
  } catch (error) {
130
248
  lastError = error;
131
249
  if (error.name === 'AbortError') {
@@ -202,6 +320,10 @@ function bundleSource(item) {
202
320
  return item.type === 'chunk' ? item.code : item.source
203
321
  }
204
322
 
323
+ // The only algorithms the SRI spec defines. Browsers reject anything else,
324
+ // which blocks the resource with no build-time error at all.
325
+ const SUPPORTED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
326
+
205
327
  /**
206
328
  * Compute an SRI string for a source that may be a string, Buffer or Uint8Array
207
329
  */
@@ -276,6 +398,48 @@ function findBundleKey(bundle, bundleKey, logger = null) {
276
398
  return candidates[0]
277
399
  }
278
400
 
401
+ /**
402
+ * Read an asset that lives in `publicDir` rather than the bundle.
403
+ *
404
+ * Files copied verbatim from `public/` never appear as bundle entries, so
405
+ * without this a perfectly normal `<script src="/sw.js">` fails the build.
406
+ * Returns null rather than throwing so the caller keeps its own missing-asset
407
+ * policy.
408
+ */
409
+ async function readPublicAsset(config, bundleKey, logger) {
410
+ const publicDir = config.publicDir;
411
+ if (!publicDir) return null
412
+
413
+ // Bundle keys come from URLs, which may be percent-encoded
414
+ let decoded;
415
+ try {
416
+ decoded = decodeURIComponent(bundleKey);
417
+ } catch {
418
+ decoded = bundleKey;
419
+ }
420
+
421
+ const filePath = path.resolve(publicDir, decoded);
422
+
423
+ // A URL must never reach outside publicDir, however it is spelled
424
+ const relative = path.relative(publicDir, filePath);
425
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
426
+ if (logger) {
427
+ logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
428
+ }
429
+ return null
430
+ }
431
+
432
+ try {
433
+ const source = await promises.readFile(filePath);
434
+ if (logger) {
435
+ logger.debug(`Resolved from publicDir: ${bundleKey}`);
436
+ }
437
+ return source
438
+ } catch {
439
+ return null
440
+ }
441
+ }
442
+
279
443
  /**
280
444
  * Calculate SRI integrity hash for a given resource
281
445
  */
@@ -291,15 +455,11 @@ async function calculateIntegrity(
291
455
  const {
292
456
  ignoreMissingAsset,
293
457
  bypassDomains,
458
+ trustDomains,
294
459
  hashAlgorithm,
295
460
  hashedAssets
296
461
  } = options;
297
462
 
298
- // Skip specified domains
299
- if (isUrlFromBypassDomain(url, bypassDomains, logger)) {
300
- return null
301
- }
302
-
303
463
  // With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
304
464
  // own build output. Those must be hashed from the bundle, not fetched - the
305
465
  // CDN may not have been deployed yet, and this is the very case SRI exists
@@ -308,10 +468,22 @@ async function calculateIntegrity(
308
468
  const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
309
469
  const fetchUrl = ownAsset ? null : externalUrl(url);
310
470
 
471
+ // Both domain options match the URL that would actually be fetched.
472
+ // `matchesDomain` needs a scheme, so matching the raw `url` silently missed
473
+ // every protocol-relative `//host/path` - and `trustDomains` below, which
474
+ // already saw the normalized form, would then disagree with `bypassDomains`
475
+ // about the same tag.
476
+ if (matchesDomain(fetchUrl ?? url, bypassDomains, logger)) {
477
+ return null
478
+ }
479
+
311
480
  let source;
312
481
  let bundleFileName = null;
313
482
  if (fetchUrl) {
314
- const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
483
+ const trusted = matchesDomain(fetchUrl, trustDomains, logger);
484
+ const isSupported = await checkResourceSupport(
485
+ fetchUrl, cacheManager.getUrlSupportCache(), logger, trusted
486
+ );
315
487
  if (!isSupported) return null
316
488
  source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
317
489
  if (!source) return null
@@ -337,13 +509,23 @@ async function calculateIntegrity(
337
509
  }
338
510
  bundleFileName = possibleMatch;
339
511
  source = bundleSource(bundle[possibleMatch]);
340
- } else if (ignoreMissingAsset) {
341
- if (logger) {
342
- logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
343
- }
344
- return null
345
512
  } else {
346
- throw new Error(`Asset ${url} not found in bundle (path: ${htmlPath}, key: ${bundleKey})`)
513
+ // Not a build output - it may still be a file copied from publicDir
514
+ source = await readPublicAsset(config, bundleKey, logger);
515
+
516
+ if (!source) {
517
+ if (ignoreMissingAsset) {
518
+ if (logger) {
519
+ logger.warn(
520
+ `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
521
+ );
522
+ }
523
+ return null
524
+ }
525
+ throw new Error(
526
+ `Asset ${url} not found in bundle or publicDir (path: ${htmlPath}, key: ${bundleKey})`
527
+ )
528
+ }
347
529
  }
348
530
  } else {
349
531
  bundleFileName = bundleKey;
@@ -431,7 +613,15 @@ function insertOffset(tag, endOffset) {
431
613
  }
432
614
 
433
615
  /**
434
- * Process a single match to create an integrity change object
616
+ * Per-tag opt out. `<script skip-sri src="...">` is left alone, and the marker
617
+ * attribute is stripped so it does not ship to the browser. More granular than
618
+ * bypassDomains, which only reaches external hosts.
619
+ */
620
+ const SKIP_SRI_ATTR_RE = /\s+skip-sri(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]+))?/i;
621
+
622
+ /**
623
+ * Process a single match into a text edit: { start, end, content }. An
624
+ * insertion has start === end; a removal has empty content.
435
625
  */
436
626
  async function processMatch(
437
627
  match,
@@ -444,6 +634,20 @@ async function processMatch(
444
634
  logger
445
635
  ) {
446
636
  const tag = match[0];
637
+
638
+ const skip = SKIP_SRI_ATTR_RE.exec(tag);
639
+ if (skip) {
640
+ return {
641
+ start: match.index + skip.index,
642
+ end: match.index + skip.index + skip[0].length,
643
+ content: '',
644
+ url: pattern.getUrl(tag),
645
+ skipped: true
646
+ }
647
+ }
648
+
649
+ // Nothing to do for a tag that already carries its own hash, and computing
650
+ // one anyway would register it for the writeBundle drift check
447
651
  if (INTEGRITY_ATTR_RE.test(tag)) return null
448
652
 
449
653
  const url = pattern.getUrl(tag);
@@ -459,15 +663,15 @@ async function processMatch(
459
663
  logger
460
664
  );
461
665
 
462
- if (integrity) {
463
- return {
464
- integrity,
465
- position: match.index + insertOffset(tag, pattern.endOffset),
466
- tag,
467
- url // For logging
468
- }
666
+ if (!integrity) return null
667
+
668
+ let content = ` integrity="${integrity}"`;
669
+ if (!CROSSORIGIN_ATTR_RE.test(tag)) {
670
+ content += ` crossorigin="${options.crossorigin}"`;
469
671
  }
470
- return null
672
+
673
+ const at = match.index + insertOffset(tag, pattern.endOffset);
674
+ return { start: at, end: at, content, url }
471
675
  }
472
676
 
473
677
  /**
@@ -538,16 +742,12 @@ async function collectIntegrityChanges(
538
742
  * Apply integrity changes to HTML content
539
743
  */
540
744
  function applyIntegrityChanges(html, changes, logger) {
541
- // Sort by position in descending order to insert from back to front
542
- changes.sort((a, b) => b.position - a.position);
745
+ // Back to front, so earlier offsets stay valid as the string is edited
746
+ changes.sort((a, b) => b.start - a.start);
543
747
 
544
- for (const { integrity, position, tag, url } of changes) {
545
- let insertText = ` integrity="${integrity}"`;
546
- if (!CROSSORIGIN_ATTR_RE.test(tag)) {
547
- insertText += ' crossorigin="anonymous"';
548
- }
549
- html = html.slice(0, position) + insertText + html.slice(position);
550
- logger.debug(`Added integrity for: ${url}`);
748
+ for (const { start, end, content, url, skipped } of changes) {
749
+ html = html.slice(0, start) + content + html.slice(end);
750
+ logger.debug(skipped ? `Skipped (skip-sri): ${url}` : `Added integrity for: ${url}`);
551
751
  }
552
752
 
553
753
  return html
@@ -704,23 +904,27 @@ class Logger {
704
904
  }
705
905
  }
706
906
 
707
- // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
708
- // adds `native:import-analysis-build`. We patch whichever (or both) is present.
907
+ // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
908
+ // `native:import-analysis-build`.
909
+ //
910
+ // Why we care where these sit: they substitute `__VITE_PRELOAD__` in entry
911
+ // chunks inside their own generateBundle, and Vite places them immediately
912
+ // AFTER `enforce: 'post'` user plugins. Measured on Vite 8.2.2, a post plugin
913
+ // is at index 25 and the analysis plugin at 26 - so by default we would hash
914
+ // an entry chunk still containing `import("./x.js"),__VITE_PRELOAD__)` while
915
+ // the written file contains `import("./x.js"),[])`.
709
916
  //
710
- // Why patch at all, rather than use `transformIndexHtml` (order: 'post') or our
711
- // own `enforce: 'post'` generateBundle? Both of those run BEFORE this plugin
712
- // substitutes `__VITE_PRELOAD__` in entry chunks, so any hash taken there
713
- // describes bytes that never ship. Measured on Vite 8.2.2: at both of those
714
- // points the entry chunk still ends `import("./about-*.js"),__VITE_PRELOAD__)`,
715
- // while the written file ends `import("./about-*.js"),[])`. Wrapping this
716
- // plugin's own handler is the only hook position after that substitution.
717
- // `test/sri.test.js > injects integrity matching the actual emitted bytes`
718
- // fails if this is ever "simplified" away.
917
+ // The fix is to move THIS plugin one place later, not to rewrite someone
918
+ // else's hook. `config.plugins` is a plain, unfrozen array at configResolved
919
+ // time and Rollup reads it afterwards, so repositioning takes effect.
920
+ // `test/sri.test.js > injects integrity matching the actual emitted bytes` is
921
+ // the pin, and the writeBundle drift check is the second net.
719
922
  const VITE_INTERNAL_ANALYSIS_PLUGINS = [
720
923
  'vite:build-import-analysis',
721
924
  'native:import-analysis-build'
722
925
  ];
723
926
  const DEFAULT_HASH_ALGORITHM = 'sha384';
927
+ const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
724
928
  const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
725
929
  const MANIFEST_FILE_NAME = 'sri-manifest.json';
726
930
  const HTML_RE = /\.html?$/;
@@ -748,16 +952,40 @@ function hashBundle(bundle, hashAlgorithm) {
748
952
  return hashes
749
953
  }
750
954
 
955
+ /**
956
+ * Reject configuration that would build cleanly and then fail in the browser.
957
+ */
958
+ function validateOptions(hashAlgorithm, crossorigin) {
959
+ if (!SUPPORTED_HASH_ALGORITHMS.includes(hashAlgorithm)) {
960
+ throw new Error(
961
+ `[${DEFAULT_PLUGIN_NAME}] unsupported hashAlgorithm "${hashAlgorithm}". ` +
962
+ `The SRI spec defines ${SUPPORTED_HASH_ALGORITHMS.join(', ')}; browsers reject ` +
963
+ `anything else, so the build would succeed and the resource would be blocked.`
964
+ )
965
+ }
966
+
967
+ if (!CROSSORIGIN_VALUES.includes(crossorigin)) {
968
+ throw new Error(
969
+ `[${DEFAULT_PLUGIN_NAME}] crossorigin must be one of ${CROSSORIGIN_VALUES.join(', ')}, ` +
970
+ `got "${crossorigin}"`
971
+ )
972
+ }
973
+ }
974
+
751
975
  function sri(options = {}) {
752
976
  const {
753
977
  ignoreMissingAsset = false,
754
978
  bypassDomains = [],
979
+ trustDomains = [],
755
980
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
981
+ crossorigin = 'anonymous',
756
982
  logLevel = 'warn',
757
983
  manifest = false,
758
984
  importmap = false
759
985
  } = options;
760
986
 
987
+ validateOptions(hashAlgorithm, crossorigin);
988
+
761
989
  // Create cache manager and logger instances for this plugin instance
762
990
  const cacheManager = new CacheManager();
763
991
  const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
@@ -765,6 +993,9 @@ function sri(options = {}) {
765
993
  // bundle fileName -> the integrity we injected, re-checked in writeBundle
766
994
  const hashedAssets = new Map();
767
995
 
996
+ let config;
997
+ let transformer;
998
+
768
999
  return {
769
1000
  name: DEFAULT_PLUGIN_NAME,
770
1001
  enforce: 'post',
@@ -803,99 +1034,98 @@ function sri(options = {}) {
803
1034
  hashedAssets.clear();
804
1035
  },
805
1036
 
806
- configResolved(config) {
807
- const transformer = createTransformer({
1037
+ configResolved(resolvedConfig) {
1038
+ config = resolvedConfig;
1039
+ transformer = createTransformer({
808
1040
  ignoreMissingAsset,
809
1041
  bypassDomains,
1042
+ trustDomains,
810
1043
  hashAlgorithm,
1044
+ crossorigin,
811
1045
  hashedAssets
812
1046
  }, config, cacheManager, logger);
813
1047
 
814
- // Both analysis plugin names can be present at once, and each is wrapped.
815
- // Without this guard the body runs twice per bundle: `emitFile` throws on
816
- // the duplicate manifest fileName, and the import map warns about the one
817
- // it just injected. If the skipped pass was the one that finalised chunk
818
- // contents, the writeBundle drift check fails the build loudly.
819
- const handled = new WeakSet();
820
-
821
- const generateBundle = async function(_, bundle) {
822
- if (handled.has(bundle)) return
823
- handled.add(bundle);
824
-
825
- // Computed before emitting anything so the manifest never hashes itself
826
- const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
827
-
828
- const htmlFiles = Object.entries(bundle).filter(
829
- ([, chunk]) =>
830
- chunk.type === 'asset' &&
831
- HTML_RE.test(chunk.fileName)
832
- );
833
-
834
- if (htmlFiles.length === 0) {
835
- // Normal for SSR / library builds, which render HTML at request time
836
- // - the manifest below is how those builds get their hashes.
837
- logger.debug('No HTML files found in bundle');
838
- }
839
-
840
- // Errors are intentionally not caught: a resource that cannot be
841
- // hashed must fail the build rather than ship without integrity.
842
- await Promise.all(
843
- htmlFiles.map(async ([name, chunk]) => {
844
- const originalContent = toText(chunk.source);
845
- let html = await transformer.transformHTML(bundle, name, originalContent);
846
-
847
- if (importmap) {
848
- const moduleIntegrity = {};
849
- const base = withTrailingSlash(config.base);
850
- for (const [fileName, integrity] of Object.entries(hashes)) {
851
- if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
852
- }
853
- html = injectImportmapIntegrity(html, moduleIntegrity, logger);
854
- }
855
-
856
- chunk.source = html;
1048
+ const plugins = config.plugins;
857
1049
 
858
- if (originalContent !== chunk.source) {
859
- logger.debug(`SRI attributes added to ${name}`);
860
- }
861
- })
862
- );
863
-
864
- if (manifest) {
865
- this.emitFile({
866
- type: 'asset',
867
- fileName: MANIFEST_FILE_NAME,
868
- source: JSON.stringify(hashes, null, 2)
869
- });
870
- logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
871
- }
872
- };
1050
+ // The last one wins: if both names are present we must follow both
1051
+ let target = -1;
1052
+ for (let i = 0; i < plugins.length; i++) {
1053
+ if (plugins[i] && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(plugins[i].name)) target = i;
1054
+ }
873
1055
 
874
- const targets = config.plugins.filter(
875
- p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
876
- );
877
- if (targets.length === 0) {
1056
+ if (target === -1) {
878
1057
  throw new Error(
879
- `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to hook into ` +
1058
+ `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
880
1059
  `(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
881
1060
  `Requires Vite 6.0.0 or higher.`
882
1061
  )
883
1062
  }
884
1063
 
885
- for (const plugin of targets) {
886
- if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
887
- const originalHandler = plugin.generateBundle.handler;
888
- plugin.generateBundle.handler = async function(...args) {
889
- await originalHandler.apply(this, args);
890
- await generateBundle.apply(this, args);
891
- };
892
- } else if (typeof plugin.generateBundle === 'function') {
893
- const originalHandler = plugin.generateBundle;
894
- plugin.generateBundle = async function(...args) {
895
- await originalHandler.apply(this, args);
896
- await generateBundle.apply(this, args);
897
- };
898
- }
1064
+ const self = plugins.findIndex(p => p && p.name === DEFAULT_PLUGIN_NAME);
1065
+ if (self === -1) {
1066
+ // Only reachable when the plugin was not registered through Vite, as
1067
+ // in a unit test driving the hook directly. Hashes may then be taken a
1068
+ // step early, which the writeBundle drift check catches.
1069
+ logger.debug('Plugin not present in config.plugins; leaving hook order alone');
1070
+ return
1071
+ }
1072
+
1073
+ if (target > self) {
1074
+ // Removing ourselves shifts target down one, so inserting at `target`
1075
+ // lands immediately after it.
1076
+ const [me] = plugins.splice(self, 1);
1077
+ plugins.splice(target, 0, me);
1078
+ logger.debug(`Repositioned after ${plugins[target - 1].name}`);
1079
+ }
1080
+ },
1081
+
1082
+ async generateBundle(_, bundle) {
1083
+ // Computed before emitting anything so the manifest never hashes itself
1084
+ const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
1085
+
1086
+ const htmlFiles = Object.entries(bundle).filter(
1087
+ ([, chunk]) =>
1088
+ chunk.type === 'asset' &&
1089
+ HTML_RE.test(chunk.fileName)
1090
+ );
1091
+
1092
+ if (htmlFiles.length === 0) {
1093
+ // Normal for SSR / library builds, which render HTML at request time
1094
+ // - the manifest below is how those builds get their hashes.
1095
+ logger.debug('No HTML files found in bundle');
1096
+ }
1097
+
1098
+ // Errors are intentionally not caught: a resource that cannot be
1099
+ // hashed must fail the build rather than ship without integrity.
1100
+ await Promise.all(
1101
+ htmlFiles.map(async ([name, chunk]) => {
1102
+ const originalContent = toText(chunk.source);
1103
+ let html = await transformer.transformHTML(bundle, name, originalContent);
1104
+
1105
+ if (importmap) {
1106
+ const moduleIntegrity = {};
1107
+ const base = withTrailingSlash(config.base);
1108
+ for (const [fileName, integrity] of Object.entries(hashes)) {
1109
+ if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
1110
+ }
1111
+ html = injectImportmapIntegrity(html, moduleIntegrity, logger);
1112
+ }
1113
+
1114
+ chunk.source = html;
1115
+
1116
+ if (originalContent !== chunk.source) {
1117
+ logger.debug(`SRI attributes added to ${name}`);
1118
+ }
1119
+ })
1120
+ );
1121
+
1122
+ if (manifest) {
1123
+ this.emitFile({
1124
+ type: 'asset',
1125
+ fileName: MANIFEST_FILE_NAME,
1126
+ source: JSON.stringify(hashes, null, 2)
1127
+ });
1128
+ logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
899
1129
  }
900
1130
  }
901
1131
  }