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