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