vite-plugin-sri4 3.1.0 → 4.1.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.
Files changed (4) hide show
  1. package/README.md +61 -11
  2. package/dist/index.cjs +415 -139
  3. package/dist/index.js +415 -139
  4. package/package.json +10 -11
package/dist/index.cjs CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var crypto = require('crypto');
6
- var path = require('path');
5
+ var node_crypto = require('node:crypto');
6
+ var path = require('node:path');
7
7
  var fetch = require('cross-fetch');
8
8
 
9
9
  /**
@@ -109,8 +109,21 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
109
109
 
110
110
  clearTimeout(timeoutId);
111
111
 
112
+ // Only `*` can be verified at build time. Injecting integrity also means
113
+ // injecting crossorigin="anonymous"; if the server answers with a
114
+ // 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.
112
118
  const corsHeader = response.headers.get('access-control-allow-origin');
113
- const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
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
+ );
126
+ }
114
127
  urlSupportCache.set(url, isSupported);
115
128
  return isSupported
116
129
  } catch (error) {
@@ -182,43 +195,104 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
182
195
  return null
183
196
  }
184
197
 
198
+ /**
199
+ * Read the hashable source out of a bundle entry (chunk code or asset source)
200
+ */
201
+ function bundleSource(item) {
202
+ return item.type === 'chunk' ? item.code : item.source
203
+ }
204
+
205
+ /**
206
+ * Compute an SRI string for a source that may be a string, Buffer or Uint8Array
207
+ */
208
+ function sriHash(source, hashAlgorithm) {
209
+ const hash = node_crypto.createHash(hashAlgorithm);
210
+ hash.update(typeof source === 'string' ? source : Buffer.from(source));
211
+ return `${hashAlgorithm}-${hash.digest('base64')}`
212
+ }
213
+
214
+ // Anything carrying a scheme (data:, blob:, invalid:) is not a path into the
215
+ // bundle. Protocol-relative `//host/path` is excluded here - it is a real HTTP
216
+ // URL and is fetched, not skipped.
217
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
218
+ const HTTP_RE = /^https?:/i;
219
+
220
+ /**
221
+ * The URL to fetch for an external resource, or null if it is not fetchable.
222
+ */
223
+ function externalUrl(url) {
224
+ if (HTTP_RE.test(url)) return url
225
+ // Protocol-relative: same origin scheme as the page, https at build time
226
+ if (url.startsWith('//')) return `https:${url}`
227
+ return null
228
+ }
229
+
185
230
  /**
186
231
  * Improved method for getting bundle keys
187
232
  */
188
233
  function getBundleKey(htmlPath, url, config) {
234
+ // Bundle keys never carry a query string or fragment
235
+ const cleanUrl = url.replace(/[?#].*$/, '');
236
+
189
237
  // Handle absolute path URLs
190
- if (url.startsWith('/')) {
238
+ if (cleanUrl.startsWith('/')) {
191
239
  // Remove leading slash to match keys in bundle
192
- return url.substring(1)
240
+ return cleanUrl.substring(1)
193
241
  }
194
242
 
195
- // Handle relative paths (when config.base is relative)
243
+ // Handle relative paths (when config.base is relative). `join`, not
244
+ // `resolve` - bundle keys are relative to the output root, and `resolve`
245
+ // would make the key absolute against the process CWD.
196
246
  if (config.base === './' || config.base === '') {
197
- return path.posix.resolve(path.posix.dirname(htmlPath), url)
247
+ return path.posix.join(path.posix.dirname(htmlPath), cleanUrl)
198
248
  }
199
249
 
200
250
  // Handle other cases, remove base prefix from URL
201
- return url.startsWith(config.base)
202
- ? url.substring(config.base.length)
203
- : url
251
+ return cleanUrl.startsWith(config.base)
252
+ ? cleanUrl.substring(config.base.length)
253
+ : cleanUrl
254
+ }
255
+
256
+ /**
257
+ * Find a bundle key for a URL that did not match exactly.
258
+ *
259
+ * Matching is anchored on a path separator so `main.js` can never match
260
+ * `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
261
+ * but wrong hash, which the browser rejects with no build-time error.
262
+ */
263
+ function findBundleKey(bundle, bundleKey, logger = null) {
264
+ const candidates = Object.keys(bundle).filter(key =>
265
+ key === bundleKey ||
266
+ key.endsWith(`/${bundleKey}`) ||
267
+ bundleKey.endsWith(`/${key}`)
268
+ );
269
+
270
+ if (candidates.length > 1 && logger) {
271
+ logger.warn(
272
+ `Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
273
+ );
274
+ }
275
+
276
+ return candidates[0]
204
277
  }
205
278
 
206
279
  /**
207
280
  * Calculate SRI integrity hash for a given resource
208
281
  */
209
282
  async function calculateIntegrity(
210
- bundle,
211
- htmlPath,
212
- url,
213
- options,
214
- config,
283
+ bundle,
284
+ htmlPath,
285
+ url,
286
+ options,
287
+ config,
215
288
  cacheManager,
216
289
  logger = null
217
290
  ) {
218
- const {
219
- ignoreMissingAsset,
220
- bypassDomains,
221
- hashAlgorithm
291
+ const {
292
+ ignoreMissingAsset,
293
+ bypassDomains,
294
+ hashAlgorithm,
295
+ hashedAssets
222
296
  } = options;
223
297
 
224
298
  // Skip specified domains
@@ -226,26 +300,43 @@ async function calculateIntegrity(
226
300
  return null
227
301
  }
228
302
 
303
+ // With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
304
+ // own build output. Those must be hashed from the bundle, not fetched - the
305
+ // CDN may not have been deployed yet, and this is the very case SRI exists
306
+ // for. Checked before the network path.
307
+ const base = config.base || '/';
308
+ const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
309
+ const fetchUrl = ownAsset ? null : externalUrl(url);
310
+
229
311
  let source;
230
- if (url.startsWith('http')) {
231
- const isSupported = await checkResourceSupport(url, cacheManager.getUrlSupportCache(), logger);
312
+ let bundleFileName = null;
313
+ if (fetchUrl) {
314
+ const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
232
315
  if (!isSupported) return null
233
- source = await fetchResource(url, cacheManager.getResourceCache(), logger);
316
+ source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
234
317
  if (!source) return null
318
+ } else if (!ownAsset && SCHEME_RE.test(url)) {
319
+ // data:/blob: and unknown schemes cannot be resolved to a bundle asset
320
+ if (logger) {
321
+ logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
322
+ }
323
+ return null
235
324
  } else {
236
325
  const bundleKey = getBundleKey(htmlPath, url, config);
237
326
  const bundleItem = bundle[bundleKey];
238
327
 
239
328
  if (!bundleItem) {
240
- // Try to find a matching item with more flexible matching
241
- const possibleMatch = Object.keys(bundle).find(key =>
242
- key.endsWith(bundleKey) || bundleKey.endsWith(key)
243
- );
329
+ // Fall back to a path-anchored suffix match to absorb hashed filenames
330
+ // AND base-prefix mismatches (e.g. URL "/base/main.js" with bare bundle
331
+ // key "main.js").
332
+ const possibleMatch = findBundleKey(bundle, bundleKey, logger);
244
333
 
245
334
  if (possibleMatch) {
246
- source = bundle[possibleMatch].type === 'chunk'
247
- ? bundle[possibleMatch].code
248
- : bundle[possibleMatch].source;
335
+ if (logger) {
336
+ logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
337
+ }
338
+ bundleFileName = possibleMatch;
339
+ source = bundleSource(bundle[possibleMatch]);
249
340
  } else if (ignoreMissingAsset) {
250
341
  if (logger) {
251
342
  logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
@@ -255,33 +346,65 @@ async function calculateIntegrity(
255
346
  throw new Error(`Asset ${url} not found in bundle (path: ${htmlPath}, key: ${bundleKey})`)
256
347
  }
257
348
  } else {
258
- source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
349
+ bundleFileName = bundleKey;
350
+ source = bundleSource(bundleItem);
259
351
  }
260
352
  }
261
353
 
262
354
  // Ensure source is a Uint8Array or string
263
355
  if (!source) return null
264
356
 
265
- if (typeof source === 'string') {
266
- return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
267
- }
357
+ const integrity = sriHash(source, hashAlgorithm);
358
+ // Recorded so writeBundle can catch a later plugin rewriting these bytes
359
+ if (bundleFileName && hashedAssets) hashedAssets.set(bundleFileName, integrity);
360
+ return integrity
361
+ }
268
362
 
269
- return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(Buffer.from(source)).digest('base64')}`
363
+ /**
364
+ * Match an attribute regardless of quoting style. Attribute order inside a tag
365
+ * is not significant in HTML, so attributes are read out of the matched tag
366
+ * rather than being baked into the tag regex.
367
+ */
368
+ function attrPattern(name) {
369
+ // Lookbehind on whitespace, not `\b` - `\b` matches after the hyphen in
370
+ // `data-src`, and getAttr takes the first match, so a decoy attribute would
371
+ // hijack the URL.
372
+ return new RegExp(`(?<=\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')
373
+ }
374
+
375
+ // Attributes are always preceded by whitespace inside a tag, so anchoring on
376
+ // it avoids matching `data-integrity`. `crossorigin` is matched with or without
377
+ // a value - Vite emits the valueless form, and duplicating it is invalid HTML.
378
+ const CROSSORIGIN_ATTR_RE = /\scrossorigin(?=[\s=>/]|$)/i;
379
+ const INTEGRITY_ATTR_RE = /\sintegrity\s*=/i;
380
+
381
+ const SRC_RE = attrPattern('src');
382
+ const HREF_RE = attrPattern('href');
383
+ const REL_RE = attrPattern('rel');
384
+
385
+ // rel values whose tags carry an integrity attribute
386
+ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
387
+
388
+ function getAttr(tag, re) {
389
+ const match = tag.match(re);
390
+ if (!match) return null
391
+ return match[1] ?? match[2] ?? match[3] ?? null
270
392
  }
271
393
 
272
- // Optimized regex patterns for better readability and efficiency
273
394
  const HTML_PATTERNS = {
274
395
  script: {
275
- regex: /<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>/g,
276
- endOffset: 10
277
- },
278
- stylesheet: {
279
- regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
280
- endOffset: 1
396
+ regex: /<script\b[^>]*><\/script>/gi,
397
+ endOffset: 10, // length of '></script>'
398
+ getUrl: tag => getAttr(tag, SRC_RE)
281
399
  },
282
- modulepreload: {
283
- regex: /<link\b[^>]*?\brel\s*=\s*["']modulepreload["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
284
- endOffset: 1
400
+ link: {
401
+ regex: /<link\b[^>]*>/gi,
402
+ endOffset: 1, // length of '>'
403
+ getUrl: tag => {
404
+ const rel = getAttr(tag, REL_RE);
405
+ if (!rel || !SRI_LINK_RELS.has(rel.trim().toLowerCase())) return null
406
+ return getAttr(tag, HREF_RE)
407
+ }
285
408
  }
286
409
  };
287
410
 
@@ -296,29 +419,42 @@ function validateHtmlInput(html, htmlPath, logger) {
296
419
  return true
297
420
  }
298
421
 
422
+ /**
423
+ * Offset inside the matched tag where new attributes should go: just before the
424
+ * closing `>`, skipping back over the self-closing slash and any whitespace so
425
+ * `<link ... />` does not become `<link ... / integrity="...">`.
426
+ */
427
+ function insertOffset(tag, endOffset) {
428
+ let at = tag.length - endOffset;
429
+ while (at > 0 && (tag[at - 1] === '/' || /\s/.test(tag[at - 1]))) at--;
430
+ return at
431
+ }
432
+
299
433
  /**
300
434
  * Process a single match to create an integrity change object
301
435
  */
302
436
  async function processMatch(
303
- match,
304
- endOffset,
305
- bundle,
306
- htmlPath,
307
- options,
308
- config,
437
+ match,
438
+ pattern,
439
+ bundle,
440
+ htmlPath,
441
+ options,
442
+ config,
309
443
  cacheManager,
310
444
  logger
311
445
  ) {
312
- const [, url] = match;
446
+ const tag = match[0];
447
+ if (INTEGRITY_ATTR_RE.test(tag)) return null
448
+
449
+ const url = pattern.getUrl(tag);
313
450
  if (!url) return null
314
451
 
315
- const end = match.index + match[0].length;
316
452
  const integrity = await calculateIntegrity(
317
- bundle,
318
- htmlPath,
319
- url,
320
- options,
321
- config,
453
+ bundle,
454
+ htmlPath,
455
+ url,
456
+ options,
457
+ config,
322
458
  cacheManager,
323
459
  logger
324
460
  );
@@ -326,7 +462,8 @@ async function processMatch(
326
462
  if (integrity) {
327
463
  return {
328
464
  integrity,
329
- position: end - endOffset,
465
+ position: match.index + insertOffset(tag, pattern.endOffset),
466
+ tag,
330
467
  url // For logging
331
468
  }
332
469
  }
@@ -337,22 +474,21 @@ async function processMatch(
337
474
  * Process matches for a specific HTML pattern
338
475
  */
339
476
  async function processPatternMatches(
340
- html,
341
- pattern,
342
- bundle,
343
- htmlPath,
344
- options,
345
- config,
477
+ html,
478
+ pattern,
479
+ bundle,
480
+ htmlPath,
481
+ options,
482
+ config,
346
483
  cacheManager,
347
484
  logger
348
485
  ) {
349
- const { regex, endOffset } = pattern;
350
- const matches = [...html.matchAll(regex)];
486
+ const matches = [...html.matchAll(pattern.regex)];
351
487
 
352
488
  // Process each match in parallel
353
489
  const matchResults = await Promise.all(
354
- matches.map(match =>
355
- processMatch(match, endOffset, bundle, htmlPath, options, config, cacheManager, logger)
490
+ matches.map(match =>
491
+ processMatch(match, pattern, bundle, htmlPath, options, config, cacheManager, logger)
356
492
  )
357
493
  );
358
494
 
@@ -364,11 +500,11 @@ async function processPatternMatches(
364
500
  * Collect all integrity changes from HTML patterns
365
501
  */
366
502
  async function collectIntegrityChanges(
367
- html,
368
- bundle,
369
- htmlPath,
370
- options,
371
- config,
503
+ html,
504
+ bundle,
505
+ htmlPath,
506
+ options,
507
+ config,
372
508
  cacheManager,
373
509
  logger
374
510
  ) {
@@ -378,12 +514,12 @@ async function collectIntegrityChanges(
378
514
  await Promise.all(
379
515
  Object.values(HTML_PATTERNS).map(async pattern => {
380
516
  const patternChanges = await processPatternMatches(
381
- html,
382
- pattern,
383
- bundle,
384
- htmlPath,
385
- options,
386
- config,
517
+ html,
518
+ pattern,
519
+ bundle,
520
+ htmlPath,
521
+ options,
522
+ config,
387
523
  cacheManager,
388
524
  logger
389
525
  );
@@ -394,13 +530,9 @@ async function collectIntegrityChanges(
394
530
  return changes
395
531
  }
396
532
 
397
- /**
398
- * Check if integrity attribute already exists in HTML segment
399
- */
400
- function hasExistingIntegrity(html, position, integrity) {
401
- const segment = html.slice(Math.max(0, position - 100), position + 100);
402
- return segment.includes(`integrity="${integrity}"`)
403
- }
533
+ // Attributes are always preceded by whitespace inside a tag, so anchoring on
534
+ // it avoids matching `data-integrity`. `crossorigin` is matched with or without
535
+ // a value - Vite emits the valueless form, and duplicating it is invalid HTML.
404
536
 
405
537
  /**
406
538
  * Apply integrity changes to HTML content
@@ -409,13 +541,11 @@ function applyIntegrityChanges(html, changes, logger) {
409
541
  // Sort by position in descending order to insert from back to front
410
542
  changes.sort((a, b) => b.position - a.position);
411
543
 
412
- for (const { integrity, position, url } of changes) {
413
- // Skip if integrity attribute already exists
414
- if (hasExistingIntegrity(html, position, integrity)) {
415
- continue
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"';
416
548
  }
417
-
418
- const insertText = ` integrity="${integrity}"`;
419
549
  html = html.slice(0, position) + insertText + html.slice(position);
420
550
  logger.debug(`Added integrity for: ${url}`);
421
551
  }
@@ -423,15 +553,49 @@ function applyIntegrityChanges(html, changes, logger) {
423
553
  return html
424
554
  }
425
555
 
556
+ const EXISTING_IMPORTMAP_RE = /<script\b[^>]*\btype\s*=\s*["']importmap["']/i;
557
+ const FIRST_SCRIPT_RE = /<script\b/i;
558
+ const HEAD_CLOSE_RE = /<\/head\s*>/i;
559
+
560
+ /**
561
+ * Inject an import map carrying an `integrity` map.
562
+ *
563
+ * This is the only mechanism that covers modules pulled in at runtime by
564
+ * `import()` / Vite's preload helper, which have no build-time HTML tag to
565
+ * rewrite. Engines without support ignore the key rather than failing.
566
+ */
567
+ function injectImportmapIntegrity(html, integrity, logger) {
568
+ if (!html || typeof html !== 'string' || Object.keys(integrity).length === 0) {
569
+ return html
570
+ }
571
+
572
+ if (EXISTING_IMPORTMAP_RE.test(html)) {
573
+ logger.warn('HTML already contains an import map; skipping SRI import map injection');
574
+ return html
575
+ }
576
+
577
+ // `<` is escaped so a filename can never close the script element early
578
+ const json = JSON.stringify({ integrity }).replace(/</g, '\\u003c');
579
+ const tag = `<script type="importmap">${json}</script>`;
580
+
581
+ // Must precede every module script, otherwise the map does not apply to them
582
+ for (const re of [FIRST_SCRIPT_RE, HEAD_CLOSE_RE]) {
583
+ const at = html.search(re);
584
+ if (at !== -1) return html.slice(0, at) + tag + html.slice(at)
585
+ }
586
+
587
+ return html + tag
588
+ }
589
+
426
590
  /**
427
591
  * Transform HTML by adding SRI integrity attributes
428
592
  */
429
593
  async function transformHTML(
430
- bundle,
431
- htmlPath,
432
- html,
433
- options,
434
- config,
594
+ bundle,
595
+ htmlPath,
596
+ html,
597
+ options,
598
+ config,
435
599
  cacheManager,
436
600
  logger
437
601
  ) {
@@ -440,11 +604,11 @@ async function transformHTML(
440
604
  }
441
605
 
442
606
  const changes = await collectIntegrityChanges(
443
- html,
444
- bundle,
445
- htmlPath,
446
- options,
447
- config,
607
+ html,
608
+ bundle,
609
+ htmlPath,
610
+ options,
611
+ config,
448
612
  cacheManager,
449
613
  logger
450
614
  );
@@ -457,9 +621,9 @@ async function transformHTML(
457
621
  */
458
622
  function createTransformer(options, config, cacheManager, logger) {
459
623
  return {
460
- transformHTML: (bundle, htmlPath, html) =>
624
+ transformHTML: (bundle, htmlPath, html) =>
461
625
  transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
462
- calculateIntegrity: (bundle, htmlPath, url) =>
626
+ calculateIntegrity: (bundle, htmlPath, url) =>
463
627
  calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
464
628
  }
465
629
  }
@@ -480,7 +644,9 @@ class Logger {
480
644
  info: 3,
481
645
  debug: 4
482
646
  };
483
- this.currentLevel = this.levels[logLevel] || this.levels.warn;
647
+ // `??`, not `||` - levels.silent is 0, which `||` treats as absent and
648
+ // silently downgrades to 'warn', the one level that must suppress output.
649
+ this.currentLevel = this.levels[logLevel] ?? this.levels.warn;
484
650
  }
485
651
 
486
652
  /**
@@ -538,88 +704,198 @@ class Logger {
538
704
  }
539
705
  }
540
706
 
541
- // Constants definition
542
- const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
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.
709
+ //
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.
719
+ const VITE_INTERNAL_ANALYSIS_PLUGINS = [
720
+ 'vite:build-import-analysis',
721
+ 'native:import-analysis-build'
722
+ ];
543
723
  const DEFAULT_HASH_ALGORITHM = 'sha384';
544
724
  const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
725
+ const MANIFEST_FILE_NAME = 'sri-manifest.json';
726
+ const HTML_RE = /\.html?$/;
727
+ const JS_MODULE_RE = /\.m?js$/;
728
+
729
+ function toText(source) {
730
+ return typeof source === 'string' ? source : Buffer.from(source).toString('utf8')
731
+ }
732
+
733
+ function withTrailingSlash(base) {
734
+ if (!base) return '/'
735
+ return base.endsWith('/') ? base : `${base}/`
736
+ }
737
+
738
+ /**
739
+ * Hash every non-HTML output, keyed by bundle file name.
740
+ */
741
+ function hashBundle(bundle, hashAlgorithm) {
742
+ const hashes = {};
743
+ for (const [fileName, item] of Object.entries(bundle)) {
744
+ if (HTML_RE.test(fileName)) continue
745
+ const source = bundleSource(item);
746
+ if (source) hashes[fileName] = sriHash(source, hashAlgorithm);
747
+ }
748
+ return hashes
749
+ }
545
750
 
546
751
  function sri(options = {}) {
547
752
  const {
548
753
  ignoreMissingAsset = false,
549
754
  bypassDomains = [],
550
755
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
551
- logLevel = 'warn'
756
+ logLevel = 'warn',
757
+ manifest = false,
758
+ importmap = false
552
759
  } = options;
553
760
 
554
761
  // Create cache manager and logger instances for this plugin instance
555
762
  const cacheManager = new CacheManager();
556
763
  const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
557
764
 
765
+ // bundle fileName -> the integrity we injected, re-checked in writeBundle
766
+ const hashedAssets = new Map();
767
+
558
768
  return {
559
769
  name: DEFAULT_PLUGIN_NAME,
560
770
  enforce: 'post',
561
771
  apply: 'build',
562
772
 
563
- // Cleanup work
564
- buildEnd() {
565
- // Clear caches
773
+ // Every generateBundle hook has run by now. A plugin that rewrites chunk
774
+ // contents after ours (plugin-legacy, in-place compression) would leave the
775
+ // injected hashes describing bytes that no longer ship - a green build that
776
+ // only fails in the browser. Fail here instead.
777
+ writeBundle(_, bundle) {
778
+ const drifted = [];
779
+ for (const [fileName, integrity] of hashedAssets) {
780
+ const item = bundle[fileName];
781
+ if (!item) continue
782
+ const source = bundleSource(item);
783
+ if (source && sriHash(source, hashAlgorithm) !== integrity) {
784
+ drifted.push(fileName);
785
+ }
786
+ }
787
+ hashedAssets.clear();
788
+
789
+ if (drifted.length > 0) {
790
+ throw new Error(
791
+ `[${DEFAULT_PLUGIN_NAME}] content changed after integrity was computed: ` +
792
+ `${drifted.join(', ')}. A plugin running after this one rewrote these ` +
793
+ `files, so the injected hashes no longer match what ships. Move that ` +
794
+ `plugin before ${DEFAULT_PLUGIN_NAME}, or drop it.`
795
+ )
796
+ }
797
+ },
798
+
799
+ // Cleanup. Note this must not be `buildEnd`, which Rollup runs before the
800
+ // output phase - clearing there would empty the caches before use.
801
+ closeBundle() {
566
802
  cacheManager.clearAll();
803
+ hashedAssets.clear();
567
804
  },
568
805
 
569
806
  configResolved(config) {
570
807
  const transformer = createTransformer({
571
808
  ignoreMissingAsset,
572
809
  bypassDomains,
573
- hashAlgorithm
810
+ hashAlgorithm,
811
+ hashedAssets
574
812
  }, config, cacheManager, logger);
575
813
 
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
+
576
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
+
577
828
  const htmlFiles = Object.entries(bundle).filter(
578
829
  ([, chunk]) =>
579
830
  chunk.type === 'asset' &&
580
- /\.html?$/.test(chunk.fileName)
831
+ HTML_RE.test(chunk.fileName)
581
832
  );
582
833
 
583
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.
584
837
  logger.debug('No HTML files found in bundle');
585
- return
586
838
  }
587
839
 
588
- // Process all HTML files in parallel
840
+ // Errors are intentionally not caught: a resource that cannot be
841
+ // hashed must fail the build rather than ship without integrity.
589
842
  await Promise.all(
590
843
  htmlFiles.map(async ([name, chunk]) => {
591
- try {
592
- const originalContent = chunk.source.toString();
593
- chunk.source = await transformer.transformHTML(bundle, name, originalContent);
594
-
595
- if (originalContent !== chunk.source) {
596
- logger.debug(`SRI attributes added to ${name}`);
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;
597
852
  }
598
- } catch (error) {
599
- logger.warn(`Error processing ${name}:`, error);
600
- // Keep original content on error
853
+ html = injectImportmapIntegrity(html, moduleIntegrity, logger);
854
+ }
855
+
856
+ chunk.source = html;
857
+
858
+ if (originalContent !== chunk.source) {
859
+ logger.debug(`SRI attributes added to ${name}`);
601
860
  }
602
861
  })
603
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
+ }
604
872
  };
605
873
 
606
- const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
607
- if (!plugin) {
608
- throw new Error(`[${DEFAULT_PLUGIN_NAME}] requires Vite 2.0.0 or higher`)
874
+ const targets = config.plugins.filter(
875
+ p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
876
+ );
877
+ if (targets.length === 0) {
878
+ throw new Error(
879
+ `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to hook into ` +
880
+ `(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
881
+ `Requires Vite 6.0.0 or higher.`
882
+ )
609
883
  }
610
884
 
611
- if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
612
- const originalHandler = plugin.generateBundle.handler;
613
- plugin.generateBundle.handler = async function(...args) {
614
- await originalHandler.apply(this, args);
615
- await generateBundle.apply(this, args);
616
- };
617
- } else if (typeof plugin.generateBundle === 'function') {
618
- const originalHandler = plugin.generateBundle;
619
- plugin.generateBundle = async function(...args) {
620
- await originalHandler.apply(this, args);
621
- await generateBundle.apply(this, args);
622
- };
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
+ }
623
899
  }
624
900
  }
625
901
  }