vite-plugin-sri4 4.0.0 → 4.2.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,6 +67,8 @@ 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
  /**
@@ -109,8 +111,21 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, retries
109
111
 
110
112
  clearTimeout(timeoutId);
111
113
 
114
+ // Only `*` can be verified at build time. Injecting integrity also means
115
+ // injecting crossorigin="anonymous"; if the server answers with a
116
+ // concrete origin that does not match wherever the HTML ends up being
117
+ // served from, that turns a working script into a blocked one. Skipping
118
+ // is the safe outcome, but say so at warn level - silence here is what
119
+ // makes an unprotected resource easy to miss.
112
120
  const corsHeader = response.headers.get('access-control-allow-origin');
113
- const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
121
+ const isSupported = response.ok && corsHeader === '*';
122
+ if (response.ok && corsHeader && corsHeader !== '*' && logger) {
123
+ logger.warn(
124
+ `Skipping SRI for ${url}: Access-Control-Allow-Origin is "${corsHeader}", not "*", ` +
125
+ 'so crossorigin="anonymous" cannot be verified at build time. ' +
126
+ 'Add the domain to bypassDomains to silence this.'
127
+ );
128
+ }
114
129
  urlSupportCache.set(url, isSupported);
115
130
  return isSupported
116
131
  } catch (error) {
@@ -182,43 +197,150 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
182
197
  return null
183
198
  }
184
199
 
200
+ /**
201
+ * Read the hashable source out of a bundle entry (chunk code or asset source)
202
+ */
203
+ function bundleSource(item) {
204
+ return item.type === 'chunk' ? item.code : item.source
205
+ }
206
+
207
+ // The only algorithms the SRI spec defines. Browsers reject anything else,
208
+ // which blocks the resource with no build-time error at all.
209
+ const SUPPORTED_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
210
+
211
+ /**
212
+ * Compute an SRI string for a source that may be a string, Buffer or Uint8Array
213
+ */
214
+ function sriHash(source, hashAlgorithm) {
215
+ const hash = node_crypto.createHash(hashAlgorithm);
216
+ hash.update(typeof source === 'string' ? source : Buffer.from(source));
217
+ return `${hashAlgorithm}-${hash.digest('base64')}`
218
+ }
219
+
220
+ // Anything carrying a scheme (data:, blob:, invalid:) is not a path into the
221
+ // bundle. Protocol-relative `//host/path` is excluded here - it is a real HTTP
222
+ // URL and is fetched, not skipped.
223
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
224
+ const HTTP_RE = /^https?:/i;
225
+
226
+ /**
227
+ * The URL to fetch for an external resource, or null if it is not fetchable.
228
+ */
229
+ function externalUrl(url) {
230
+ if (HTTP_RE.test(url)) return url
231
+ // Protocol-relative: same origin scheme as the page, https at build time
232
+ if (url.startsWith('//')) return `https:${url}`
233
+ return null
234
+ }
235
+
185
236
  /**
186
237
  * Improved method for getting bundle keys
187
238
  */
188
239
  function getBundleKey(htmlPath, url, config) {
240
+ // Bundle keys never carry a query string or fragment
241
+ const cleanUrl = url.replace(/[?#].*$/, '');
242
+
189
243
  // Handle absolute path URLs
190
- if (url.startsWith('/')) {
244
+ if (cleanUrl.startsWith('/')) {
191
245
  // Remove leading slash to match keys in bundle
192
- return url.substring(1)
246
+ return cleanUrl.substring(1)
193
247
  }
194
248
 
195
- // Handle relative paths (when config.base is relative)
249
+ // Handle relative paths (when config.base is relative). `join`, not
250
+ // `resolve` - bundle keys are relative to the output root, and `resolve`
251
+ // would make the key absolute against the process CWD.
196
252
  if (config.base === './' || config.base === '') {
197
- return path.posix.resolve(path.posix.dirname(htmlPath), url)
253
+ return path.posix.join(path.posix.dirname(htmlPath), cleanUrl)
198
254
  }
199
255
 
200
256
  // Handle other cases, remove base prefix from URL
201
- return url.startsWith(config.base)
202
- ? url.substring(config.base.length)
203
- : url
257
+ return cleanUrl.startsWith(config.base)
258
+ ? cleanUrl.substring(config.base.length)
259
+ : cleanUrl
260
+ }
261
+
262
+ /**
263
+ * Find a bundle key for a URL that did not match exactly.
264
+ *
265
+ * Matching is anchored on a path separator so `main.js` can never match
266
+ * `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
267
+ * but wrong hash, which the browser rejects with no build-time error.
268
+ */
269
+ function findBundleKey(bundle, bundleKey, logger = null) {
270
+ const candidates = Object.keys(bundle).filter(key =>
271
+ key === bundleKey ||
272
+ key.endsWith(`/${bundleKey}`) ||
273
+ bundleKey.endsWith(`/${key}`)
274
+ );
275
+
276
+ if (candidates.length > 1 && logger) {
277
+ logger.warn(
278
+ `Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
279
+ );
280
+ }
281
+
282
+ return candidates[0]
283
+ }
284
+
285
+ /**
286
+ * Read an asset that lives in `publicDir` rather than the bundle.
287
+ *
288
+ * Files copied verbatim from `public/` never appear as bundle entries, so
289
+ * without this a perfectly normal `<script src="/sw.js">` fails the build.
290
+ * Returns null rather than throwing so the caller keeps its own missing-asset
291
+ * policy.
292
+ */
293
+ async function readPublicAsset(config, bundleKey, logger) {
294
+ const publicDir = config.publicDir;
295
+ if (!publicDir) return null
296
+
297
+ // Bundle keys come from URLs, which may be percent-encoded
298
+ let decoded;
299
+ try {
300
+ decoded = decodeURIComponent(bundleKey);
301
+ } catch {
302
+ decoded = bundleKey;
303
+ }
304
+
305
+ const filePath = path.resolve(publicDir, decoded);
306
+
307
+ // A URL must never reach outside publicDir, however it is spelled
308
+ const relative = path.relative(publicDir, filePath);
309
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
310
+ if (logger) {
311
+ logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
312
+ }
313
+ return null
314
+ }
315
+
316
+ try {
317
+ const source = await promises.readFile(filePath);
318
+ if (logger) {
319
+ logger.debug(`Resolved from publicDir: ${bundleKey}`);
320
+ }
321
+ return source
322
+ } catch {
323
+ return null
324
+ }
204
325
  }
205
326
 
206
327
  /**
207
328
  * Calculate SRI integrity hash for a given resource
208
329
  */
209
330
  async function calculateIntegrity(
210
- bundle,
211
- htmlPath,
212
- url,
213
- options,
214
- config,
331
+ bundle,
332
+ htmlPath,
333
+ url,
334
+ options,
335
+ config,
215
336
  cacheManager,
216
337
  logger = null
217
338
  ) {
218
- const {
219
- ignoreMissingAsset,
220
- bypassDomains,
221
- hashAlgorithm
339
+ const {
340
+ ignoreMissingAsset,
341
+ bypassDomains,
342
+ hashAlgorithm,
343
+ hashedAssets
222
344
  } = options;
223
345
 
224
346
  // Skip specified domains
@@ -226,68 +348,121 @@ async function calculateIntegrity(
226
348
  return null
227
349
  }
228
350
 
351
+ // With an absolute `base` (assets on a CDN) Vite emits absolute URLs for our
352
+ // own build output. Those must be hashed from the bundle, not fetched - the
353
+ // CDN may not have been deployed yet, and this is the very case SRI exists
354
+ // for. Checked before the network path.
355
+ const base = config.base || '/';
356
+ const ownAsset = (HTTP_RE.test(base) || base.startsWith('//')) && url.startsWith(base);
357
+ const fetchUrl = ownAsset ? null : externalUrl(url);
358
+
229
359
  let source;
230
- if (url.startsWith('http')) {
231
- const isSupported = await checkResourceSupport(url, cacheManager.getUrlSupportCache(), logger);
360
+ let bundleFileName = null;
361
+ if (fetchUrl) {
362
+ const isSupported = await checkResourceSupport(fetchUrl, cacheManager.getUrlSupportCache(), logger);
232
363
  if (!isSupported) return null
233
- source = await fetchResource(url, cacheManager.getResourceCache(), logger);
364
+ source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
234
365
  if (!source) return null
366
+ } else if (!ownAsset && SCHEME_RE.test(url)) {
367
+ // data:/blob: and unknown schemes cannot be resolved to a bundle asset
368
+ if (logger) {
369
+ logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
370
+ }
371
+ return null
235
372
  } else {
236
373
  const bundleKey = getBundleKey(htmlPath, url, config);
237
374
  const bundleItem = bundle[bundleKey];
238
375
 
239
376
  if (!bundleItem) {
240
- // Fall back to suffix match in either direction to absorb hashed
241
- // filenames AND base-prefix mismatches (e.g. URL "/base/main.js" with
242
- // bare bundle key "main.js"). A mismatch here just produces a wrong
243
- // integrity hash, which the browser rejects — failure-closed.
244
- const possibleMatch = Object.keys(bundle).find(key =>
245
- key.endsWith(bundleKey) || bundleKey.endsWith(key)
246
- );
377
+ // Fall back to a path-anchored suffix match to absorb hashed filenames
378
+ // AND base-prefix mismatches (e.g. URL "/base/main.js" with bare bundle
379
+ // key "main.js").
380
+ const possibleMatch = findBundleKey(bundle, bundleKey, logger);
247
381
 
248
382
  if (possibleMatch) {
249
383
  if (logger) {
250
384
  logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
251
385
  }
252
- source = bundle[possibleMatch].type === 'chunk'
253
- ? bundle[possibleMatch].code
254
- : bundle[possibleMatch].source;
255
- } else if (ignoreMissingAsset) {
256
- if (logger) {
257
- logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
258
- }
259
- return null
386
+ bundleFileName = possibleMatch;
387
+ source = bundleSource(bundle[possibleMatch]);
260
388
  } else {
261
- throw new Error(`Asset ${url} not found in bundle (path: ${htmlPath}, key: ${bundleKey})`)
389
+ // Not a build output - it may still be a file copied from publicDir
390
+ source = await readPublicAsset(config, bundleKey, logger);
391
+
392
+ if (!source) {
393
+ if (ignoreMissingAsset) {
394
+ if (logger) {
395
+ logger.warn(
396
+ `Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
397
+ );
398
+ }
399
+ return null
400
+ }
401
+ throw new Error(
402
+ `Asset ${url} not found in bundle or publicDir (path: ${htmlPath}, key: ${bundleKey})`
403
+ )
404
+ }
262
405
  }
263
406
  } else {
264
- source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
407
+ bundleFileName = bundleKey;
408
+ source = bundleSource(bundleItem);
265
409
  }
266
410
  }
267
411
 
268
412
  // Ensure source is a Uint8Array or string
269
413
  if (!source) return null
270
414
 
271
- if (typeof source === 'string') {
272
- return `${hashAlgorithm}-${node_crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
273
- }
415
+ const integrity = sriHash(source, hashAlgorithm);
416
+ // Recorded so writeBundle can catch a later plugin rewriting these bytes
417
+ if (bundleFileName && hashedAssets) hashedAssets.set(bundleFileName, integrity);
418
+ return integrity
419
+ }
420
+
421
+ /**
422
+ * Match an attribute regardless of quoting style. Attribute order inside a tag
423
+ * is not significant in HTML, so attributes are read out of the matched tag
424
+ * rather than being baked into the tag regex.
425
+ */
426
+ function attrPattern(name) {
427
+ // Lookbehind on whitespace, not `\b` - `\b` matches after the hyphen in
428
+ // `data-src`, and getAttr takes the first match, so a decoy attribute would
429
+ // hijack the URL.
430
+ return new RegExp(`(?<=\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')
431
+ }
432
+
433
+ // Attributes are always preceded by whitespace inside a tag, so anchoring on
434
+ // it avoids matching `data-integrity`. `crossorigin` is matched with or without
435
+ // a value - Vite emits the valueless form, and duplicating it is invalid HTML.
436
+ const CROSSORIGIN_ATTR_RE = /\scrossorigin(?=[\s=>/]|$)/i;
437
+ const INTEGRITY_ATTR_RE = /\sintegrity\s*=/i;
438
+
439
+ const SRC_RE = attrPattern('src');
440
+ const HREF_RE = attrPattern('href');
441
+ const REL_RE = attrPattern('rel');
274
442
 
275
- return `${hashAlgorithm}-${node_crypto.createHash(hashAlgorithm).update(Buffer.from(source)).digest('base64')}`
443
+ // rel values whose tags carry an integrity attribute
444
+ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
445
+
446
+ function getAttr(tag, re) {
447
+ const match = tag.match(re);
448
+ if (!match) return null
449
+ return match[1] ?? match[2] ?? match[3] ?? null
276
450
  }
277
451
 
278
- // Optimized regex patterns for better readability and efficiency
279
452
  const HTML_PATTERNS = {
280
453
  script: {
281
- regex: /<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>/g,
282
- endOffset: 10
283
- },
284
- stylesheet: {
285
- regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
286
- endOffset: 1
454
+ regex: /<script\b[^>]*><\/script>/gi,
455
+ endOffset: 10, // length of '></script>'
456
+ getUrl: tag => getAttr(tag, SRC_RE)
287
457
  },
288
- modulepreload: {
289
- regex: /<link\b[^>]*?\brel\s*=\s*["']modulepreload["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
290
- endOffset: 1
458
+ link: {
459
+ regex: /<link\b[^>]*>/gi,
460
+ endOffset: 1, // length of '>'
461
+ getUrl: tag => {
462
+ const rel = getAttr(tag, REL_RE);
463
+ if (!rel || !SRI_LINK_RELS.has(rel.trim().toLowerCase())) return null
464
+ return getAttr(tag, HREF_RE)
465
+ }
291
466
  }
292
467
  };
293
468
 
@@ -303,63 +478,97 @@ function validateHtmlInput(html, htmlPath, logger) {
303
478
  }
304
479
 
305
480
  /**
306
- * Process a single match to create an integrity change object
481
+ * Offset inside the matched tag where new attributes should go: just before the
482
+ * closing `>`, skipping back over the self-closing slash and any whitespace so
483
+ * `<link ... />` does not become `<link ... / integrity="...">`.
484
+ */
485
+ function insertOffset(tag, endOffset) {
486
+ let at = tag.length - endOffset;
487
+ while (at > 0 && (tag[at - 1] === '/' || /\s/.test(tag[at - 1]))) at--;
488
+ return at
489
+ }
490
+
491
+ /**
492
+ * Per-tag opt out. `<script skip-sri src="...">` is left alone, and the marker
493
+ * attribute is stripped so it does not ship to the browser. More granular than
494
+ * bypassDomains, which only reaches external hosts.
495
+ */
496
+ const SKIP_SRI_ATTR_RE = /\s+skip-sri(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>/]+))?/i;
497
+
498
+ /**
499
+ * Process a single match into a text edit: { start, end, content }. An
500
+ * insertion has start === end; a removal has empty content.
307
501
  */
308
502
  async function processMatch(
309
- match,
310
- endOffset,
311
- bundle,
312
- htmlPath,
313
- options,
314
- config,
503
+ match,
504
+ pattern,
505
+ bundle,
506
+ htmlPath,
507
+ options,
508
+ config,
315
509
  cacheManager,
316
510
  logger
317
511
  ) {
318
- const [, url] = match;
512
+ const tag = match[0];
513
+
514
+ const skip = SKIP_SRI_ATTR_RE.exec(tag);
515
+ if (skip) {
516
+ return {
517
+ start: match.index + skip.index,
518
+ end: match.index + skip.index + skip[0].length,
519
+ content: '',
520
+ url: pattern.getUrl(tag),
521
+ skipped: true
522
+ }
523
+ }
524
+
525
+ // Nothing to do for a tag that already carries its own hash, and computing
526
+ // one anyway would register it for the writeBundle drift check
527
+ if (INTEGRITY_ATTR_RE.test(tag)) return null
528
+
529
+ const url = pattern.getUrl(tag);
319
530
  if (!url) return null
320
531
 
321
- const end = match.index + match[0].length;
322
532
  const integrity = await calculateIntegrity(
323
- bundle,
324
- htmlPath,
325
- url,
326
- options,
327
- config,
533
+ bundle,
534
+ htmlPath,
535
+ url,
536
+ options,
537
+ config,
328
538
  cacheManager,
329
539
  logger
330
540
  );
331
541
 
332
- if (integrity) {
333
- return {
334
- integrity,
335
- position: end - endOffset,
336
- tagStart: match.index,
337
- url // For logging
338
- }
542
+ if (!integrity) return null
543
+
544
+ let content = ` integrity="${integrity}"`;
545
+ if (!CROSSORIGIN_ATTR_RE.test(tag)) {
546
+ content += ` crossorigin="${options.crossorigin}"`;
339
547
  }
340
- return null
548
+
549
+ const at = match.index + insertOffset(tag, pattern.endOffset);
550
+ return { start: at, end: at, content, url }
341
551
  }
342
552
 
343
553
  /**
344
554
  * Process matches for a specific HTML pattern
345
555
  */
346
556
  async function processPatternMatches(
347
- html,
348
- pattern,
349
- bundle,
350
- htmlPath,
351
- options,
352
- config,
557
+ html,
558
+ pattern,
559
+ bundle,
560
+ htmlPath,
561
+ options,
562
+ config,
353
563
  cacheManager,
354
564
  logger
355
565
  ) {
356
- const { regex, endOffset } = pattern;
357
- const matches = [...html.matchAll(regex)];
566
+ const matches = [...html.matchAll(pattern.regex)];
358
567
 
359
568
  // Process each match in parallel
360
569
  const matchResults = await Promise.all(
361
- matches.map(match =>
362
- processMatch(match, endOffset, bundle, htmlPath, options, config, cacheManager, logger)
570
+ matches.map(match =>
571
+ processMatch(match, pattern, bundle, htmlPath, options, config, cacheManager, logger)
363
572
  )
364
573
  );
365
574
 
@@ -371,11 +580,11 @@ async function processPatternMatches(
371
580
  * Collect all integrity changes from HTML patterns
372
581
  */
373
582
  async function collectIntegrityChanges(
374
- html,
375
- bundle,
376
- htmlPath,
377
- options,
378
- config,
583
+ html,
584
+ bundle,
585
+ htmlPath,
586
+ options,
587
+ config,
379
588
  cacheManager,
380
589
  logger
381
590
  ) {
@@ -385,12 +594,12 @@ async function collectIntegrityChanges(
385
594
  await Promise.all(
386
595
  Object.values(HTML_PATTERNS).map(async pattern => {
387
596
  const patternChanges = await processPatternMatches(
388
- html,
389
- pattern,
390
- bundle,
391
- htmlPath,
392
- options,
393
- config,
597
+ html,
598
+ pattern,
599
+ bundle,
600
+ htmlPath,
601
+ options,
602
+ config,
394
603
  cacheManager,
395
604
  logger
396
605
  );
@@ -401,56 +610,68 @@ async function collectIntegrityChanges(
401
610
  return changes
402
611
  }
403
612
 
404
- const CROSSORIGIN_ATTR_RE = /\bcrossorigin\s*=/i;
405
- const INTEGRITY_ATTR_RE = /\bintegrity\s*=/i;
613
+ // Attributes are always preceded by whitespace inside a tag, so anchoring on
614
+ // it avoids matching `data-integrity`. `crossorigin` is matched with or without
615
+ // a value - Vite emits the valueless form, and duplicating it is invalid HTML.
406
616
 
407
617
  /**
408
- * Check if integrity attribute already exists in the same tag
618
+ * Apply integrity changes to HTML content
409
619
  */
410
- function hasExistingIntegrity(html, tagStart, position) {
411
- return INTEGRITY_ATTR_RE.test(html.slice(tagStart, position))
412
- }
620
+ function applyIntegrityChanges(html, changes, logger) {
621
+ // Back to front, so earlier offsets stay valid as the string is edited
622
+ changes.sort((a, b) => b.start - a.start);
413
623
 
414
- /**
415
- * Check if crossorigin attribute already exists in the same tag
416
- */
417
- function hasExistingCrossorigin(html, tagStart, position) {
418
- return CROSSORIGIN_ATTR_RE.test(html.slice(tagStart, position))
624
+ for (const { start, end, content, url, skipped } of changes) {
625
+ html = html.slice(0, start) + content + html.slice(end);
626
+ logger.debug(skipped ? `Skipped (skip-sri): ${url}` : `Added integrity for: ${url}`);
627
+ }
628
+
629
+ return html
419
630
  }
420
631
 
632
+ const EXISTING_IMPORTMAP_RE = /<script\b[^>]*\btype\s*=\s*["']importmap["']/i;
633
+ const FIRST_SCRIPT_RE = /<script\b/i;
634
+ const HEAD_CLOSE_RE = /<\/head\s*>/i;
635
+
421
636
  /**
422
- * Apply integrity changes to HTML content
637
+ * Inject an import map carrying an `integrity` map.
638
+ *
639
+ * This is the only mechanism that covers modules pulled in at runtime by
640
+ * `import()` / Vite's preload helper, which have no build-time HTML tag to
641
+ * rewrite. Engines without support ignore the key rather than failing.
423
642
  */
424
- function applyIntegrityChanges(html, changes, logger) {
425
- // Sort by position in descending order to insert from back to front
426
- changes.sort((a, b) => b.position - a.position);
643
+ function injectImportmapIntegrity(html, integrity, logger) {
644
+ if (!html || typeof html !== 'string' || Object.keys(integrity).length === 0) {
645
+ return html
646
+ }
427
647
 
428
- for (const { integrity, position, tagStart, url } of changes) {
429
- // Skip if integrity attribute already exists on this tag
430
- if (hasExistingIntegrity(html, tagStart, position)) {
431
- continue
432
- }
648
+ if (EXISTING_IMPORTMAP_RE.test(html)) {
649
+ logger.warn('HTML already contains an import map; skipping SRI import map injection');
650
+ return html
651
+ }
433
652
 
434
- let insertText = ` integrity="${integrity}"`;
435
- if (!hasExistingCrossorigin(html, tagStart, position)) {
436
- insertText += ' crossorigin="anonymous"';
437
- }
438
- html = html.slice(0, position) + insertText + html.slice(position);
439
- logger.debug(`Added integrity for: ${url}`);
653
+ // `<` is escaped so a filename can never close the script element early
654
+ const json = JSON.stringify({ integrity }).replace(/</g, '\\u003c');
655
+ const tag = `<script type="importmap">${json}</script>`;
656
+
657
+ // Must precede every module script, otherwise the map does not apply to them
658
+ for (const re of [FIRST_SCRIPT_RE, HEAD_CLOSE_RE]) {
659
+ const at = html.search(re);
660
+ if (at !== -1) return html.slice(0, at) + tag + html.slice(at)
440
661
  }
441
662
 
442
- return html
663
+ return html + tag
443
664
  }
444
665
 
445
666
  /**
446
667
  * Transform HTML by adding SRI integrity attributes
447
668
  */
448
669
  async function transformHTML(
449
- bundle,
450
- htmlPath,
451
- html,
452
- options,
453
- config,
670
+ bundle,
671
+ htmlPath,
672
+ html,
673
+ options,
674
+ config,
454
675
  cacheManager,
455
676
  logger
456
677
  ) {
@@ -459,11 +680,11 @@ async function transformHTML(
459
680
  }
460
681
 
461
682
  const changes = await collectIntegrityChanges(
462
- html,
463
- bundle,
464
- htmlPath,
465
- options,
466
- config,
683
+ html,
684
+ bundle,
685
+ htmlPath,
686
+ options,
687
+ config,
467
688
  cacheManager,
468
689
  logger
469
690
  );
@@ -476,9 +697,9 @@ async function transformHTML(
476
697
  */
477
698
  function createTransformer(options, config, cacheManager, logger) {
478
699
  return {
479
- transformHTML: (bundle, htmlPath, html) =>
700
+ transformHTML: (bundle, htmlPath, html) =>
480
701
  transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger),
481
- calculateIntegrity: (bundle, htmlPath, url) =>
702
+ calculateIntegrity: (bundle, htmlPath, url) =>
482
703
  calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
483
704
  }
484
705
  }
@@ -499,7 +720,9 @@ class Logger {
499
720
  info: 3,
500
721
  debug: 4
501
722
  };
502
- this.currentLevel = this.levels[logLevel] || this.levels.warn;
723
+ // `??`, not `||` - levels.silent is 0, which `||` treats as absent and
724
+ // silently downgrades to 'warn', the one level that must suppress output.
725
+ this.currentLevel = this.levels[logLevel] ?? this.levels.warn;
503
726
  }
504
727
 
505
728
  /**
@@ -557,101 +780,226 @@ class Logger {
557
780
  }
558
781
  }
559
782
 
560
- // Constants definition
561
- // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
562
- // adds `native:import-analysis-build`. We patch whichever (or both) is present.
783
+ // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
784
+ // `native:import-analysis-build`.
785
+ //
786
+ // Why we care where these sit: they substitute `__VITE_PRELOAD__` in entry
787
+ // chunks inside their own generateBundle, and Vite places them immediately
788
+ // AFTER `enforce: 'post'` user plugins. Measured on Vite 8.2.2, a post plugin
789
+ // is at index 25 and the analysis plugin at 26 - so by default we would hash
790
+ // an entry chunk still containing `import("./x.js"),__VITE_PRELOAD__)` while
791
+ // the written file contains `import("./x.js"),[])`.
792
+ //
793
+ // The fix is to move THIS plugin one place later, not to rewrite someone
794
+ // else's hook. `config.plugins` is a plain, unfrozen array at configResolved
795
+ // time and Rollup reads it afterwards, so repositioning takes effect.
796
+ // `test/sri.test.js > injects integrity matching the actual emitted bytes` is
797
+ // the pin, and the writeBundle drift check is the second net.
563
798
  const VITE_INTERNAL_ANALYSIS_PLUGINS = [
564
799
  'vite:build-import-analysis',
565
800
  'native:import-analysis-build'
566
801
  ];
567
802
  const DEFAULT_HASH_ALGORITHM = 'sha384';
803
+ const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
568
804
  const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
805
+ const MANIFEST_FILE_NAME = 'sri-manifest.json';
806
+ const HTML_RE = /\.html?$/;
807
+ const JS_MODULE_RE = /\.m?js$/;
808
+
809
+ function toText(source) {
810
+ return typeof source === 'string' ? source : Buffer.from(source).toString('utf8')
811
+ }
812
+
813
+ function withTrailingSlash(base) {
814
+ if (!base) return '/'
815
+ return base.endsWith('/') ? base : `${base}/`
816
+ }
817
+
818
+ /**
819
+ * Hash every non-HTML output, keyed by bundle file name.
820
+ */
821
+ function hashBundle(bundle, hashAlgorithm) {
822
+ const hashes = {};
823
+ for (const [fileName, item] of Object.entries(bundle)) {
824
+ if (HTML_RE.test(fileName)) continue
825
+ const source = bundleSource(item);
826
+ if (source) hashes[fileName] = sriHash(source, hashAlgorithm);
827
+ }
828
+ return hashes
829
+ }
830
+
831
+ /**
832
+ * Reject configuration that would build cleanly and then fail in the browser.
833
+ */
834
+ function validateOptions(hashAlgorithm, crossorigin) {
835
+ if (!SUPPORTED_HASH_ALGORITHMS.includes(hashAlgorithm)) {
836
+ throw new Error(
837
+ `[${DEFAULT_PLUGIN_NAME}] unsupported hashAlgorithm "${hashAlgorithm}". ` +
838
+ `The SRI spec defines ${SUPPORTED_HASH_ALGORITHMS.join(', ')}; browsers reject ` +
839
+ `anything else, so the build would succeed and the resource would be blocked.`
840
+ )
841
+ }
842
+
843
+ if (!CROSSORIGIN_VALUES.includes(crossorigin)) {
844
+ throw new Error(
845
+ `[${DEFAULT_PLUGIN_NAME}] crossorigin must be one of ${CROSSORIGIN_VALUES.join(', ')}, ` +
846
+ `got "${crossorigin}"`
847
+ )
848
+ }
849
+ }
569
850
 
570
851
  function sri(options = {}) {
571
852
  const {
572
853
  ignoreMissingAsset = false,
573
854
  bypassDomains = [],
574
855
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
575
- logLevel = 'warn'
856
+ crossorigin = 'anonymous',
857
+ logLevel = 'warn',
858
+ manifest = false,
859
+ importmap = false
576
860
  } = options;
577
861
 
862
+ validateOptions(hashAlgorithm, crossorigin);
863
+
578
864
  // Create cache manager and logger instances for this plugin instance
579
865
  const cacheManager = new CacheManager();
580
866
  const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
581
867
 
868
+ // bundle fileName -> the integrity we injected, re-checked in writeBundle
869
+ const hashedAssets = new Map();
870
+
871
+ let config;
872
+ let transformer;
873
+
582
874
  return {
583
875
  name: DEFAULT_PLUGIN_NAME,
584
876
  enforce: 'post',
585
877
  apply: 'build',
586
878
 
587
- // Cleanup work
588
- buildEnd() {
589
- // Clear caches
879
+ // Every generateBundle hook has run by now. A plugin that rewrites chunk
880
+ // contents after ours (plugin-legacy, in-place compression) would leave the
881
+ // injected hashes describing bytes that no longer ship - a green build that
882
+ // only fails in the browser. Fail here instead.
883
+ writeBundle(_, bundle) {
884
+ const drifted = [];
885
+ for (const [fileName, integrity] of hashedAssets) {
886
+ const item = bundle[fileName];
887
+ if (!item) continue
888
+ const source = bundleSource(item);
889
+ if (source && sriHash(source, hashAlgorithm) !== integrity) {
890
+ drifted.push(fileName);
891
+ }
892
+ }
893
+ hashedAssets.clear();
894
+
895
+ if (drifted.length > 0) {
896
+ throw new Error(
897
+ `[${DEFAULT_PLUGIN_NAME}] content changed after integrity was computed: ` +
898
+ `${drifted.join(', ')}. A plugin running after this one rewrote these ` +
899
+ `files, so the injected hashes no longer match what ships. Move that ` +
900
+ `plugin before ${DEFAULT_PLUGIN_NAME}, or drop it.`
901
+ )
902
+ }
903
+ },
904
+
905
+ // Cleanup. Note this must not be `buildEnd`, which Rollup runs before the
906
+ // output phase - clearing there would empty the caches before use.
907
+ closeBundle() {
590
908
  cacheManager.clearAll();
909
+ hashedAssets.clear();
591
910
  },
592
911
 
593
- configResolved(config) {
594
- const transformer = createTransformer({
912
+ configResolved(resolvedConfig) {
913
+ config = resolvedConfig;
914
+ transformer = createTransformer({
595
915
  ignoreMissingAsset,
596
916
  bypassDomains,
597
- hashAlgorithm
917
+ hashAlgorithm,
918
+ crossorigin,
919
+ hashedAssets
598
920
  }, config, cacheManager, logger);
599
921
 
600
- const generateBundle = async function(_, bundle) {
601
- const htmlFiles = Object.entries(bundle).filter(
602
- ([, chunk]) =>
603
- chunk.type === 'asset' &&
604
- /\.html?$/.test(chunk.fileName)
605
- );
606
-
607
- if (htmlFiles.length === 0) {
608
- logger.debug('No HTML files found in bundle');
609
- return
610
- }
922
+ const plugins = config.plugins;
611
923
 
612
- // Process all HTML files in parallel
613
- await Promise.all(
614
- htmlFiles.map(async ([name, chunk]) => {
615
- try {
616
- const originalContent = chunk.source.toString();
617
- chunk.source = await transformer.transformHTML(bundle, name, originalContent);
618
-
619
- if (originalContent !== chunk.source) {
620
- logger.debug(`SRI attributes added to ${name}`);
621
- }
622
- } catch (error) {
623
- logger.warn(`Error processing ${name}:`, error);
624
- // Keep original content on error
625
- }
626
- })
627
- );
628
- };
924
+ // The last one wins: if both names are present we must follow both
925
+ let target = -1;
926
+ for (let i = 0; i < plugins.length; i++) {
927
+ if (plugins[i] && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(plugins[i].name)) target = i;
928
+ }
629
929
 
630
- const targets = config.plugins.filter(
631
- p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
632
- );
633
- if (targets.length === 0) {
930
+ if (target === -1) {
634
931
  throw new Error(
635
- `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to hook into ` +
932
+ `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
636
933
  `(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
637
934
  `Requires Vite 6.0.0 or higher.`
638
935
  )
639
936
  }
640
937
 
641
- for (const plugin of targets) {
642
- if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
643
- const originalHandler = plugin.generateBundle.handler;
644
- plugin.generateBundle.handler = async function(...args) {
645
- await originalHandler.apply(this, args);
646
- await generateBundle.apply(this, args);
647
- };
648
- } else if (typeof plugin.generateBundle === 'function') {
649
- const originalHandler = plugin.generateBundle;
650
- plugin.generateBundle = async function(...args) {
651
- await originalHandler.apply(this, args);
652
- await generateBundle.apply(this, args);
653
- };
654
- }
938
+ const self = plugins.findIndex(p => p && p.name === DEFAULT_PLUGIN_NAME);
939
+ if (self === -1) {
940
+ // Only reachable when the plugin was not registered through Vite, as
941
+ // in a unit test driving the hook directly. Hashes may then be taken a
942
+ // step early, which the writeBundle drift check catches.
943
+ logger.debug('Plugin not present in config.plugins; leaving hook order alone');
944
+ return
945
+ }
946
+
947
+ if (target > self) {
948
+ // Removing ourselves shifts target down one, so inserting at `target`
949
+ // lands immediately after it.
950
+ const [me] = plugins.splice(self, 1);
951
+ plugins.splice(target, 0, me);
952
+ logger.debug(`Repositioned after ${plugins[target - 1].name}`);
953
+ }
954
+ },
955
+
956
+ async generateBundle(_, bundle) {
957
+ // Computed before emitting anything so the manifest never hashes itself
958
+ const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
959
+
960
+ const htmlFiles = Object.entries(bundle).filter(
961
+ ([, chunk]) =>
962
+ chunk.type === 'asset' &&
963
+ HTML_RE.test(chunk.fileName)
964
+ );
965
+
966
+ if (htmlFiles.length === 0) {
967
+ // Normal for SSR / library builds, which render HTML at request time
968
+ // - the manifest below is how those builds get their hashes.
969
+ logger.debug('No HTML files found in bundle');
970
+ }
971
+
972
+ // Errors are intentionally not caught: a resource that cannot be
973
+ // hashed must fail the build rather than ship without integrity.
974
+ await Promise.all(
975
+ htmlFiles.map(async ([name, chunk]) => {
976
+ const originalContent = toText(chunk.source);
977
+ let html = await transformer.transformHTML(bundle, name, originalContent);
978
+
979
+ if (importmap) {
980
+ const moduleIntegrity = {};
981
+ const base = withTrailingSlash(config.base);
982
+ for (const [fileName, integrity] of Object.entries(hashes)) {
983
+ if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
984
+ }
985
+ html = injectImportmapIntegrity(html, moduleIntegrity, logger);
986
+ }
987
+
988
+ chunk.source = html;
989
+
990
+ if (originalContent !== chunk.source) {
991
+ logger.debug(`SRI attributes added to ${name}`);
992
+ }
993
+ })
994
+ );
995
+
996
+ if (manifest) {
997
+ this.emitFile({
998
+ type: 'asset',
999
+ fileName: MANIFEST_FILE_NAME,
1000
+ source: JSON.stringify(hashes, null, 2)
1001
+ });
1002
+ logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
655
1003
  }
656
1004
  }
657
1005
  }