vite-plugin-sri4 4.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Zac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -14,6 +14,7 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
14
14
  - [Plugin Options](#plugin-options)
15
15
  - [Dynamic Routes](#dynamic-routes)
16
16
  - [When SRI Actually Helps](#when-sri-actually-helps)
17
+ - [How It Attaches Hashes](#how-it-attaches-hashes)
17
18
  - [Example Project](#example-project)
18
19
  - [Best Practices](#best-practices)
19
20
  - [Troubleshooting](#troubleshooting)
@@ -26,7 +27,9 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
26
27
  - **Automatic SRI Generation:** Computes SRI hashes for assets (chunks and files) using a configurable algorithm (default is `sha384`).
27
28
  - **HTML Injection:** Automatically injects `integrity` and `crossorigin` attributes into `<script>` and `<link>` tags in your HTML.
28
29
  - **CORS Support Check:** For external resources, a CORS check is performed to verify access via `Access-Control-Allow-Origin`.
29
- - **Bypass Domains:** Option to specify domains to bypass SRI injection.
30
+ - **Bypass Domains:** Option to specify domains to bypass SRI injection, plus a `skip-sri` attribute to opt out a single tag.
31
+ - **Public Directory Support:** Assets served verbatim from `publicDir` are hashed from disk, not just bundle outputs.
32
+ - **Zero Dependencies:** No runtime dependencies, and TypeScript definitions are included.
30
33
  - **Missing Asset Handling:** Configurable warning suppression for missing assets.
31
34
  - **Robust Content Support:** Handles various content types including strings, Buffer, and Uint8Array.
32
35
  - **Dynamic Routes:** Optional import map integrity and an SRI manifest cover `import()`-loaded chunks and SSR builds, which have no build-time HTML tag to rewrite.
@@ -50,8 +53,10 @@ import sri from 'vite-plugin-sri4';
50
53
  export default defineConfig({
51
54
  plugins: [
52
55
  sri({
53
- // Optional. The security hash algorithm. Defaults to "sha384".
56
+ // Optional. 'sha256' | 'sha384' | 'sha512'. Defaults to 'sha384'.
54
57
  hashAlgorithm: 'sha384',
58
+ // Optional. 'anonymous' | 'use-credentials'. Defaults to 'anonymous'.
59
+ crossorigin: 'anonymous',
55
60
  // Optional. Domains to bypass SRI injection.
56
61
  bypassDomains: ['example.com'],
57
62
  // Optional. Suppress warnings for missing assets.
@@ -85,11 +90,13 @@ Output:
85
90
  ## Plugin Options
86
91
 
87
92
  * `hashAlgorithm` (string):
88
- The hash algorithm used for computing SRI. Default is `sha384`. You may change it to other supported algorithms like `sha256` or `sha512`.
93
+ The hash algorithm used for computing SRI. One of `sha256`, `sha384` (default) or `sha512` the only three the SRI spec defines. Anything else fails at startup rather than producing an attribute browsers silently reject.
94
+ * `crossorigin` (string):
95
+ Value for the injected `crossorigin` attribute: `anonymous` (default) or `use-credentials`. Use the latter for a CDN that requires cookies or HTTP auth. Tags that already declare a `crossorigin` are left alone.
89
96
  * `bypassDomains` (Array<string>):
90
97
  Array of domain names where SRI injection should be skipped. This allows external resources from specified domains to be excluded from SRI checks (for example, when they may not support CORS).
91
98
  * `ignoreMissingAsset` (boolean):
92
- When true, suppresses warnings for assets that are not found in the bundle. Default is `false`.
99
+ When true, warns instead of failing the build for assets found in neither the bundle nor `publicDir`. Default is `false`, which fails the build rather than shipping a tag with no integrity.
93
100
  * `logLevel` (string):
94
101
  Log verbosity. One of `silent`, `error`, `warn`, `info`, `debug`. Default is `warn`. Use `debug` to see per-resource decisions during the build.
95
102
  * `importmap` (boolean):
@@ -134,6 +141,40 @@ SRI is worth the most when your HTML and your assets have **different trust boun
134
141
 
135
142
  If everything is served from a single origin, SRI buys much less than it appears to: an attacker who can rewrite `/assets/index-abc123.js` on your server can usually rewrite the `index.html` carrying its hash just as easily. It is not useless - it narrows some deploy and cache-layer mistakes - but for same-origin builds, a Content Security Policy and Vite's default hashed, immutable filenames do more for you than SRI does. Enable it because it is cheap, not because it closes the hole you think it closes.
136
143
 
144
+ ### Skipping a Single Tag
145
+
146
+ `bypassDomains` only reaches external hosts. To exclude one specific element, add `skip-sri` to it. The attribute is stripped from the output:
147
+
148
+ ```html
149
+ <script skip-sri src="/legacy.js"></script>
150
+ ```
151
+
152
+ ```html
153
+ <!-- built output -->
154
+ <script src="/legacy.js"></script>
155
+ ```
156
+
157
+ ## How It Attaches Hashes
158
+
159
+ There are three places a Vite plugin can compute SRI hashes, and they are not equivalent. This one matters more than it looks, so it is worth writing down.
160
+
161
+ **In `transformIndexHtml`.** The obvious choice, and the one that reads best — you get the finished HTML and the bundle on the context. It produces wrong hashes for entry chunks. Vite's import-analysis plugin substitutes `__VITE_PRELOAD__` inside its own `generateBundle`, which runs *after* `transformIndexHtml`, so an entry chunk still reads `import("./route.js"), __VITE_PRELOAD__)` at that point while the written file reads `import("./route.js"), [])`. The hash describes bytes that never ship, and the browser rejects the file with no build error at all.
162
+
163
+ **In a plain `enforce: 'post'` `generateBundle`.** Same problem. Vite places its import-analysis plugin immediately after post user plugins, so a post hook is still one step too early.
164
+
165
+ **Where this plugin does it.** During `configResolved` it moves itself after that plugin in `config.plugins`, then works in an ordinary `generateBundle`. Measured on Vite 8.2.2, hashing the entry chunk:
166
+
167
+ | Hook | Entry chunk | Matches shipped file |
168
+ |---|---|---|
169
+ | `transformIndexHtml` (post) | `io6MKsmc4G5y` | ✗ |
170
+ | `generateBundle` (post) | `io6MKsmc4G5y` | ✗ |
171
+ | after repositioning | `lvFyraHkqPN0` | ✓ |
172
+ | written file | `lvFyraHkqPN0` | — |
173
+
174
+ Only the entry chunk is affected, so a build without a dynamic import will not reveal the difference. As a second safeguard, every hashed file is re-hashed in `writeBundle` and the build fails if anything changed after the hash was taken.
175
+
176
+ This ordering constraint was first identified by [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3), which this plugin began as a fork of. Beyond it, this plugin adds `crossorigin` injection, a CORS pre-check with timeouts and retries for external resources, import map and manifest output for dynamically imported routes, `publicDir` resolution, and the drift check above.
177
+
137
178
  ## Example Project
138
179
 
139
180
  The plugin includes an example project in the `example` directory that demonstrates its usage with a simple Vite application. To try it:
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
  /**
@@ -202,6 +204,10 @@ function bundleSource(item) {
202
204
  return item.type === 'chunk' ? item.code : item.source
203
205
  }
204
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
+
205
211
  /**
206
212
  * Compute an SRI string for a source that may be a string, Buffer or Uint8Array
207
213
  */
@@ -276,6 +282,48 @@ function findBundleKey(bundle, bundleKey, logger = null) {
276
282
  return candidates[0]
277
283
  }
278
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
+ }
325
+ }
326
+
279
327
  /**
280
328
  * Calculate SRI integrity hash for a given resource
281
329
  */
@@ -337,13 +385,23 @@ async function calculateIntegrity(
337
385
  }
338
386
  bundleFileName = possibleMatch;
339
387
  source = bundleSource(bundle[possibleMatch]);
340
- } else if (ignoreMissingAsset) {
341
- if (logger) {
342
- logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
343
- }
344
- return null
345
388
  } else {
346
- 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
+ }
347
405
  }
348
406
  } else {
349
407
  bundleFileName = bundleKey;
@@ -431,7 +489,15 @@ function insertOffset(tag, endOffset) {
431
489
  }
432
490
 
433
491
  /**
434
- * Process a single match to create an integrity change object
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.
435
501
  */
436
502
  async function processMatch(
437
503
  match,
@@ -444,6 +510,20 @@ async function processMatch(
444
510
  logger
445
511
  ) {
446
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
447
527
  if (INTEGRITY_ATTR_RE.test(tag)) return null
448
528
 
449
529
  const url = pattern.getUrl(tag);
@@ -459,15 +539,15 @@ async function processMatch(
459
539
  logger
460
540
  );
461
541
 
462
- if (integrity) {
463
- return {
464
- integrity,
465
- position: match.index + insertOffset(tag, pattern.endOffset),
466
- tag,
467
- url // For logging
468
- }
542
+ if (!integrity) return null
543
+
544
+ let content = ` integrity="${integrity}"`;
545
+ if (!CROSSORIGIN_ATTR_RE.test(tag)) {
546
+ content += ` crossorigin="${options.crossorigin}"`;
469
547
  }
470
- return null
548
+
549
+ const at = match.index + insertOffset(tag, pattern.endOffset);
550
+ return { start: at, end: at, content, url }
471
551
  }
472
552
 
473
553
  /**
@@ -538,16 +618,12 @@ async function collectIntegrityChanges(
538
618
  * Apply integrity changes to HTML content
539
619
  */
540
620
  function applyIntegrityChanges(html, changes, logger) {
541
- // Sort by position in descending order to insert from back to front
542
- changes.sort((a, b) => b.position - a.position);
621
+ // Back to front, so earlier offsets stay valid as the string is edited
622
+ changes.sort((a, b) => b.start - a.start);
543
623
 
544
- for (const { integrity, position, tag, url } of changes) {
545
- let insertText = ` integrity="${integrity}"`;
546
- if (!CROSSORIGIN_ATTR_RE.test(tag)) {
547
- insertText += ' crossorigin="anonymous"';
548
- }
549
- html = html.slice(0, position) + insertText + html.slice(position);
550
- logger.debug(`Added integrity for: ${url}`);
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}`);
551
627
  }
552
628
 
553
629
  return html
@@ -704,23 +780,27 @@ class Logger {
704
780
  }
705
781
  }
706
782
 
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.
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"),[])`.
709
792
  //
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.
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.
719
798
  const VITE_INTERNAL_ANALYSIS_PLUGINS = [
720
799
  'vite:build-import-analysis',
721
800
  'native:import-analysis-build'
722
801
  ];
723
802
  const DEFAULT_HASH_ALGORITHM = 'sha384';
803
+ const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
724
804
  const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
725
805
  const MANIFEST_FILE_NAME = 'sri-manifest.json';
726
806
  const HTML_RE = /\.html?$/;
@@ -748,16 +828,39 @@ function hashBundle(bundle, hashAlgorithm) {
748
828
  return hashes
749
829
  }
750
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
+ }
850
+
751
851
  function sri(options = {}) {
752
852
  const {
753
853
  ignoreMissingAsset = false,
754
854
  bypassDomains = [],
755
855
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
856
+ crossorigin = 'anonymous',
756
857
  logLevel = 'warn',
757
858
  manifest = false,
758
859
  importmap = false
759
860
  } = options;
760
861
 
862
+ validateOptions(hashAlgorithm, crossorigin);
863
+
761
864
  // Create cache manager and logger instances for this plugin instance
762
865
  const cacheManager = new CacheManager();
763
866
  const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
@@ -765,6 +868,9 @@ function sri(options = {}) {
765
868
  // bundle fileName -> the integrity we injected, re-checked in writeBundle
766
869
  const hashedAssets = new Map();
767
870
 
871
+ let config;
872
+ let transformer;
873
+
768
874
  return {
769
875
  name: DEFAULT_PLUGIN_NAME,
770
876
  enforce: 'post',
@@ -803,99 +909,97 @@ function sri(options = {}) {
803
909
  hashedAssets.clear();
804
910
  },
805
911
 
806
- configResolved(config) {
807
- const transformer = createTransformer({
912
+ configResolved(resolvedConfig) {
913
+ config = resolvedConfig;
914
+ transformer = createTransformer({
808
915
  ignoreMissingAsset,
809
916
  bypassDomains,
810
917
  hashAlgorithm,
918
+ crossorigin,
811
919
  hashedAssets
812
920
  }, config, cacheManager, logger);
813
921
 
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();
922
+ const plugins = config.plugins;
820
923
 
821
- const generateBundle = async function(_, bundle) {
822
- if (handled.has(bundle)) return
823
- handled.add(bundle);
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
+ }
824
929
 
825
- // Computed before emitting anything so the manifest never hashes itself
826
- const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
930
+ if (target === -1) {
931
+ throw new Error(
932
+ `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
933
+ `(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
934
+ `Requires Vite 6.0.0 or higher.`
935
+ )
936
+ }
827
937
 
828
- const htmlFiles = Object.entries(bundle).filter(
829
- ([, chunk]) =>
830
- chunk.type === 'asset' &&
831
- HTML_RE.test(chunk.fileName)
832
- );
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
+ }
833
946
 
834
- if (htmlFiles.length === 0) {
835
- // Normal for SSR / library builds, which render HTML at request time
836
- // - the manifest below is how those builds get their hashes.
837
- logger.debug('No HTML files found in bundle');
838
- }
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
+ },
839
955
 
840
- // Errors are intentionally not caught: a resource that cannot be
841
- // hashed must fail the build rather than ship without integrity.
842
- await Promise.all(
843
- htmlFiles.map(async ([name, chunk]) => {
844
- const originalContent = toText(chunk.source);
845
- let html = await transformer.transformHTML(bundle, name, originalContent);
846
-
847
- if (importmap) {
848
- const moduleIntegrity = {};
849
- const base = withTrailingSlash(config.base);
850
- for (const [fileName, integrity] of Object.entries(hashes)) {
851
- if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
852
- }
853
- html = injectImportmapIntegrity(html, moduleIntegrity, logger);
854
- }
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
+ );
855
965
 
856
- chunk.source = html;
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
+ }
857
971
 
858
- if (originalContent !== chunk.source) {
859
- logger.debug(`SRI attributes added to ${name}`);
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;
860
984
  }
861
- })
862
- );
985
+ html = injectImportmapIntegrity(html, moduleIntegrity, logger);
986
+ }
863
987
 
864
- if (manifest) {
865
- this.emitFile({
866
- type: 'asset',
867
- fileName: MANIFEST_FILE_NAME,
868
- source: JSON.stringify(hashes, null, 2)
869
- });
870
- logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
871
- }
872
- };
988
+ chunk.source = html;
873
989
 
874
- const targets = config.plugins.filter(
875
- p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
990
+ if (originalContent !== chunk.source) {
991
+ logger.debug(`SRI attributes added to ${name}`);
992
+ }
993
+ })
876
994
  );
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
- )
883
- }
884
995
 
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
- }
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`);
899
1003
  }
900
1004
  }
901
1005
  }
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
  /**
@@ -198,6 +200,10 @@ function bundleSource(item) {
198
200
  return item.type === 'chunk' ? item.code : item.source
199
201
  }
200
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
+
201
207
  /**
202
208
  * Compute an SRI string for a source that may be a string, Buffer or Uint8Array
203
209
  */
@@ -272,6 +278,48 @@ function findBundleKey(bundle, bundleKey, logger = null) {
272
278
  return candidates[0]
273
279
  }
274
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
+ }
321
+ }
322
+
275
323
  /**
276
324
  * Calculate SRI integrity hash for a given resource
277
325
  */
@@ -333,13 +381,23 @@ async function calculateIntegrity(
333
381
  }
334
382
  bundleFileName = possibleMatch;
335
383
  source = bundleSource(bundle[possibleMatch]);
336
- } else if (ignoreMissingAsset) {
337
- if (logger) {
338
- logger.warn(`Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
339
- }
340
- return null
341
384
  } else {
342
- 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
+ }
343
401
  }
344
402
  } else {
345
403
  bundleFileName = bundleKey;
@@ -427,7 +485,15 @@ function insertOffset(tag, endOffset) {
427
485
  }
428
486
 
429
487
  /**
430
- * Process a single match to create an integrity change object
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.
431
497
  */
432
498
  async function processMatch(
433
499
  match,
@@ -440,6 +506,20 @@ async function processMatch(
440
506
  logger
441
507
  ) {
442
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
443
523
  if (INTEGRITY_ATTR_RE.test(tag)) return null
444
524
 
445
525
  const url = pattern.getUrl(tag);
@@ -455,15 +535,15 @@ async function processMatch(
455
535
  logger
456
536
  );
457
537
 
458
- if (integrity) {
459
- return {
460
- integrity,
461
- position: match.index + insertOffset(tag, pattern.endOffset),
462
- tag,
463
- url // For logging
464
- }
538
+ if (!integrity) return null
539
+
540
+ let content = ` integrity="${integrity}"`;
541
+ if (!CROSSORIGIN_ATTR_RE.test(tag)) {
542
+ content += ` crossorigin="${options.crossorigin}"`;
465
543
  }
466
- return null
544
+
545
+ const at = match.index + insertOffset(tag, pattern.endOffset);
546
+ return { start: at, end: at, content, url }
467
547
  }
468
548
 
469
549
  /**
@@ -534,16 +614,12 @@ async function collectIntegrityChanges(
534
614
  * Apply integrity changes to HTML content
535
615
  */
536
616
  function applyIntegrityChanges(html, changes, logger) {
537
- // Sort by position in descending order to insert from back to front
538
- changes.sort((a, b) => b.position - a.position);
617
+ // Back to front, so earlier offsets stay valid as the string is edited
618
+ changes.sort((a, b) => b.start - a.start);
539
619
 
540
- for (const { integrity, position, tag, url } of changes) {
541
- let insertText = ` integrity="${integrity}"`;
542
- if (!CROSSORIGIN_ATTR_RE.test(tag)) {
543
- insertText += ' crossorigin="anonymous"';
544
- }
545
- html = html.slice(0, position) + insertText + html.slice(position);
546
- logger.debug(`Added integrity for: ${url}`);
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}`);
547
623
  }
548
624
 
549
625
  return html
@@ -700,23 +776,27 @@ class Logger {
700
776
  }
701
777
  }
702
778
 
703
- // Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path
704
- // adds `native:import-analysis-build`. We patch whichever (or both) is present.
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"),[])`.
705
788
  //
706
- // Why patch at all, rather than use `transformIndexHtml` (order: 'post') or our
707
- // own `enforce: 'post'` generateBundle? Both of those run BEFORE this plugin
708
- // substitutes `__VITE_PRELOAD__` in entry chunks, so any hash taken there
709
- // describes bytes that never ship. Measured on Vite 8.2.2: at both of those
710
- // points the entry chunk still ends `import("./about-*.js"),__VITE_PRELOAD__)`,
711
- // while the written file ends `import("./about-*.js"),[])`. Wrapping this
712
- // plugin's own handler is the only hook position after that substitution.
713
- // `test/sri.test.js > injects integrity matching the actual emitted bytes`
714
- // fails if this is ever "simplified" away.
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.
715
794
  const VITE_INTERNAL_ANALYSIS_PLUGINS = [
716
795
  'vite:build-import-analysis',
717
796
  'native:import-analysis-build'
718
797
  ];
719
798
  const DEFAULT_HASH_ALGORITHM = 'sha384';
799
+ const CROSSORIGIN_VALUES = ['anonymous', 'use-credentials'];
720
800
  const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
721
801
  const MANIFEST_FILE_NAME = 'sri-manifest.json';
722
802
  const HTML_RE = /\.html?$/;
@@ -744,16 +824,39 @@ function hashBundle(bundle, hashAlgorithm) {
744
824
  return hashes
745
825
  }
746
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
+ }
846
+
747
847
  function sri(options = {}) {
748
848
  const {
749
849
  ignoreMissingAsset = false,
750
850
  bypassDomains = [],
751
851
  hashAlgorithm = DEFAULT_HASH_ALGORITHM,
852
+ crossorigin = 'anonymous',
752
853
  logLevel = 'warn',
753
854
  manifest = false,
754
855
  importmap = false
755
856
  } = options;
756
857
 
858
+ validateOptions(hashAlgorithm, crossorigin);
859
+
757
860
  // Create cache manager and logger instances for this plugin instance
758
861
  const cacheManager = new CacheManager();
759
862
  const logger = new Logger(logLevel, DEFAULT_PLUGIN_NAME);
@@ -761,6 +864,9 @@ function sri(options = {}) {
761
864
  // bundle fileName -> the integrity we injected, re-checked in writeBundle
762
865
  const hashedAssets = new Map();
763
866
 
867
+ let config;
868
+ let transformer;
869
+
764
870
  return {
765
871
  name: DEFAULT_PLUGIN_NAME,
766
872
  enforce: 'post',
@@ -799,99 +905,97 @@ function sri(options = {}) {
799
905
  hashedAssets.clear();
800
906
  },
801
907
 
802
- configResolved(config) {
803
- const transformer = createTransformer({
908
+ configResolved(resolvedConfig) {
909
+ config = resolvedConfig;
910
+ transformer = createTransformer({
804
911
  ignoreMissingAsset,
805
912
  bypassDomains,
806
913
  hashAlgorithm,
914
+ crossorigin,
807
915
  hashedAssets
808
916
  }, config, cacheManager, logger);
809
917
 
810
- // Both analysis plugin names can be present at once, and each is wrapped.
811
- // Without this guard the body runs twice per bundle: `emitFile` throws on
812
- // the duplicate manifest fileName, and the import map warns about the one
813
- // it just injected. If the skipped pass was the one that finalised chunk
814
- // contents, the writeBundle drift check fails the build loudly.
815
- const handled = new WeakSet();
918
+ const plugins = config.plugins;
816
919
 
817
- const generateBundle = async function(_, bundle) {
818
- if (handled.has(bundle)) return
819
- handled.add(bundle);
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
+ }
820
925
 
821
- // Computed before emitting anything so the manifest never hashes itself
822
- const hashes = manifest || importmap ? hashBundle(bundle, hashAlgorithm) : null;
926
+ if (target === -1) {
927
+ throw new Error(
928
+ `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to run after ` +
929
+ `(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
930
+ `Requires Vite 6.0.0 or higher.`
931
+ )
932
+ }
823
933
 
824
- const htmlFiles = Object.entries(bundle).filter(
825
- ([, chunk]) =>
826
- chunk.type === 'asset' &&
827
- HTML_RE.test(chunk.fileName)
828
- );
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
+ }
829
942
 
830
- if (htmlFiles.length === 0) {
831
- // Normal for SSR / library builds, which render HTML at request time
832
- // - the manifest below is how those builds get their hashes.
833
- logger.debug('No HTML files found in bundle');
834
- }
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
+ },
835
951
 
836
- // Errors are intentionally not caught: a resource that cannot be
837
- // hashed must fail the build rather than ship without integrity.
838
- await Promise.all(
839
- htmlFiles.map(async ([name, chunk]) => {
840
- const originalContent = toText(chunk.source);
841
- let html = await transformer.transformHTML(bundle, name, originalContent);
842
-
843
- if (importmap) {
844
- const moduleIntegrity = {};
845
- const base = withTrailingSlash(config.base);
846
- for (const [fileName, integrity] of Object.entries(hashes)) {
847
- if (JS_MODULE_RE.test(fileName)) moduleIntegrity[base + fileName] = integrity;
848
- }
849
- html = injectImportmapIntegrity(html, moduleIntegrity, logger);
850
- }
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
+ );
851
961
 
852
- chunk.source = html;
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
+ }
853
967
 
854
- if (originalContent !== chunk.source) {
855
- logger.debug(`SRI attributes added to ${name}`);
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;
856
980
  }
857
- })
858
- );
981
+ html = injectImportmapIntegrity(html, moduleIntegrity, logger);
982
+ }
859
983
 
860
- if (manifest) {
861
- this.emitFile({
862
- type: 'asset',
863
- fileName: MANIFEST_FILE_NAME,
864
- source: JSON.stringify(hashes, null, 2)
865
- });
866
- logger.debug(`Emitted ${MANIFEST_FILE_NAME} with ${Object.keys(hashes).length} entries`);
867
- }
868
- };
984
+ chunk.source = html;
869
985
 
870
- const targets = config.plugins.filter(
871
- p => p && VITE_INTERNAL_ANALYSIS_PLUGINS.includes(p.name)
986
+ if (originalContent !== chunk.source) {
987
+ logger.debug(`SRI attributes added to ${name}`);
988
+ }
989
+ })
872
990
  );
873
- if (targets.length === 0) {
874
- throw new Error(
875
- `[${DEFAULT_PLUGIN_NAME}] could not find a Vite import-analysis plugin to hook into ` +
876
- `(looked for: ${VITE_INTERNAL_ANALYSIS_PLUGINS.join(', ')}). ` +
877
- `Requires Vite 6.0.0 or higher.`
878
- )
879
- }
880
991
 
881
- for (const plugin of targets) {
882
- if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
883
- const originalHandler = plugin.generateBundle.handler;
884
- plugin.generateBundle.handler = async function(...args) {
885
- await originalHandler.apply(this, args);
886
- await generateBundle.apply(this, args);
887
- };
888
- } else if (typeof plugin.generateBundle === 'function') {
889
- const originalHandler = plugin.generateBundle;
890
- plugin.generateBundle = async function(...args) {
891
- await originalHandler.apply(this, args);
892
- await generateBundle.apply(this, args);
893
- };
894
- }
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`);
895
999
  }
896
1000
  }
897
1001
  }
package/package.json CHANGED
@@ -1,31 +1,31 @@
1
1
  {
2
2
  "name": "vite-plugin-sri4",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "A Vite plugin to generate Subresource Integrity (SRI) hashes for output files.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
8
8
  "exports": {
9
9
  ".": {
10
+ "types": "./types/index.d.ts",
10
11
  "import": "./dist/index.js",
11
12
  "require": "./dist/index.cjs"
12
13
  }
13
14
  },
14
15
  "files": [
15
- "dist"
16
+ "dist",
17
+ "types",
18
+ "LICENSE"
16
19
  ],
17
20
  "scripts": {
18
21
  "build": "rollup -c",
19
22
  "dev": "rollup -c -w",
20
- "lint": "oxlint src test example rollup.config.mjs vitest.config.js",
23
+ "lint": "oxlint src test types example rollup.config.mjs vitest.config.js",
21
24
  "test": "vitest run",
22
25
  "test:watch": "vitest",
23
26
  "test:coverage": "vitest run --coverage",
24
27
  "prepublishOnly": "npm run build"
25
28
  },
26
- "dependencies": {
27
- "cross-fetch": "^4.1.0"
28
- },
29
29
  "peerDependencies": {
30
30
  "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
31
31
  },
@@ -45,7 +45,7 @@
45
45
  "subresource integrity"
46
46
  ],
47
47
  "author": "Zac",
48
- "license": "ISC",
48
+ "license": "MIT",
49
49
  "publishConfig": {
50
50
  "access": "public"
51
51
  },
@@ -56,5 +56,9 @@
56
56
  "bugs": {
57
57
  "url": "https://github.com/7a6163/vite-plugin-sri4/issues"
58
58
  },
59
- "homepage": "https://github.com/7a6163/vite-plugin-sri4#readme"
59
+ "homepage": "https://github.com/7a6163/vite-plugin-sri4#readme",
60
+ "types": "./types/index.d.ts",
61
+ "engines": {
62
+ "node": ">=18"
63
+ }
60
64
  }
@@ -0,0 +1,60 @@
1
+ import type { Plugin } from 'vite'
2
+
3
+ /** The only algorithms the SRI spec defines; browsers reject anything else. */
4
+ export type SriHashAlgorithm = 'sha256' | 'sha384' | 'sha512'
5
+
6
+ export interface SriOptions {
7
+ /**
8
+ * Hash algorithm used to compute the integrity value.
9
+ * @default 'sha384'
10
+ */
11
+ hashAlgorithm?: SriHashAlgorithm
12
+
13
+ /**
14
+ * Value for the injected `crossorigin` attribute. Tags that already declare
15
+ * one are left alone.
16
+ * @default 'anonymous'
17
+ */
18
+ crossorigin?: 'anonymous' | 'use-credentials'
19
+
20
+ /**
21
+ * Hostnames to leave untouched. Matches the host itself and its subdomains.
22
+ * Only applies to external (http/https) URLs; use the `skip-sri` attribute
23
+ * on a tag to opt a single element out.
24
+ * @default []
25
+ */
26
+ bypassDomains?: string[]
27
+
28
+ /**
29
+ * Warn instead of failing the build when an asset resolves to neither a
30
+ * bundle entry nor a file in `publicDir`.
31
+ * @default false
32
+ */
33
+ ignoreMissingAsset?: boolean
34
+
35
+ /**
36
+ * Log verbosity.
37
+ * @default 'warn'
38
+ */
39
+ logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'
40
+
41
+ /**
42
+ * Inject `<script type="importmap">` carrying an `integrity` map for every
43
+ * JS chunk, covering modules loaded at runtime by `import()` that have no
44
+ * build-time tag to rewrite.
45
+ * @default false
46
+ */
47
+ importmap?: boolean
48
+
49
+ /**
50
+ * Emit `sri-manifest.json` mapping every non-HTML output file to its hash,
51
+ * for servers that render HTML per request.
52
+ * @default false
53
+ */
54
+ manifest?: boolean
55
+ }
56
+
57
+ declare function sri(options?: SriOptions): Plugin
58
+
59
+ export default sri
60
+ export { sri }