vite-plugin-sri4 5.0.0 → 5.1.1
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/README.md +64 -9
- package/dist/index.cjs +66 -138
- package/dist/index.js +66 -138
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vite-plugin-sri4
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+
[](https://www.npmjs.com/package/vite-plugin-sri4)
|
|
4
4
|
[](https://codecov.io/gh/7a6163/vite-plugin-sri4)
|
|
5
5
|

|
|
6
6
|
|
|
@@ -16,9 +16,11 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
16
16
|
- [External Resources](#external-resources)
|
|
17
17
|
- [When SRI Actually Helps](#when-sri-actually-helps)
|
|
18
18
|
- [How It Attaches Hashes](#how-it-attaches-hashes)
|
|
19
|
+
- [Differences from vite-plugin-sri3](#differences-from-vite-plugin-sri3)
|
|
19
20
|
- [Example Project](#example-project)
|
|
20
21
|
- [Best Practices](#best-practices)
|
|
21
22
|
- [Troubleshooting](#troubleshooting)
|
|
23
|
+
- [Testing](#testing)
|
|
22
24
|
- [Contributing](#contributing)
|
|
23
25
|
- [Inspiration](#inspiration)
|
|
24
26
|
- [License](#license)
|
|
@@ -144,9 +146,11 @@ The `writeBundle` drift check above covers **your own build outputs only**. Ever
|
|
|
144
146
|
|
|
145
147
|
An external URL pointing at someone else's origin is different. It is fetched **once, at build time, from your build machine**, and the hash is taken from that copy. An `integrity` attribute pins those bytes forever, so it is only correct on a URL whose bytes never change — and the origin is the only party that knows whether that is true.
|
|
146
148
|
|
|
147
|
-
So the plugin asks it.
|
|
149
|
+
So the plugin asks it. One `GET` does the whole job — it carries both the bytes to hash and the headers the answer depends on. There is no separate `HEAD` probe, so a host that serves `GET` and refuses `HEAD` is not a problem; `js.tappaysdk.com` answers `403` to `HEAD` and `200` to `GET`, and is read correctly.
|
|
148
150
|
|
|
149
|
-
|
|
151
|
+
An external resource is hashed only when **all three** hold:
|
|
152
|
+
|
|
153
|
+
1. **The request succeeds.** A non-2xx response leaves nothing to check.
|
|
150
154
|
2. **`Access-Control-Allow-Origin: *`.** Injecting `integrity` also injects `crossorigin`, so a response scoped to one specific origin — or to none — would turn a working resource into a blocked one.
|
|
151
155
|
3. **The origin declares the URL immutable**: `Cache-Control: immutable`, or a `max-age` of a year or more — and nothing in the same header contradicting it. `private`, `no-store` and `no-cache` each veto it: freshness and shareability are orthogonal, so a per-client response can carry a long `max-age`, and `no-cache, max-age=<long>` is a real CDN spelling of "cache it, but revalidate every time". Or the host is in `trustDomains`.
|
|
152
156
|
|
|
@@ -180,13 +184,28 @@ A blacklist would have to catch every one of the bottom rows individually, and t
|
|
|
180
184
|
|
|
181
185
|
**Pin a version in the URL.** `unpkg.com/react@18.3.1/…` answers `max-age=31536000`; `unpkg.com/react@18/…` answers `max-age=60`. Same for jsdelivr. This is the fix, not a workaround — a floating URL and an integrity attribute are contradictory by construction.
|
|
182
186
|
|
|
183
|
-
**Or vouch for the host** when you know it is stable and it just does not say so:
|
|
187
|
+
**Or vouch for the host** when you know it is stable and it just does not say so. The shape to look for is a URL that already carries a version, served by an origin that simply sends no `Cache-Control` at all:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
https://js.tappaysdk.com/sdk/tpdirect/v5.19.2
|
|
191
|
+
|
|
192
|
+
access-control-allow-origin: *
|
|
193
|
+
(no cache-control header)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The version is in the path, so those bytes are as fixed as any `immutable` response — the origin just never says so. That is what `trustDomains` is for:
|
|
184
197
|
|
|
185
198
|
```js
|
|
186
|
-
sri({ trustDomains: ['
|
|
199
|
+
sri({ trustDomains: ['js.tappaysdk.com'] })
|
|
187
200
|
```
|
|
188
201
|
|
|
189
|
-
|
|
202
|
+
**When not to use it.** `trustDomains` overrides the one check that stands between you and a hash that stops matching. Do not point it at:
|
|
203
|
+
|
|
204
|
+
- **a URL without a version in it** — `js.stripe.com/v3/`, `cdn.tailwindcss.com`, `connect.facebook.net/en_US/sdk.js`. Stripe documents that `v3/` must not be pinned; forcing a hash onto it produces a page that works until their next deploy.
|
|
205
|
+
- **a floating range** — `unpkg.com/react@18/…` resolves to whatever 18.x is current.
|
|
206
|
+
- **a host that serves per-client responses** — `fonts.googleapis.com` answers `private` for a reason.
|
|
207
|
+
|
|
208
|
+
The test is not "do I trust this vendor". It is "will these exact bytes still be at this exact URL after their next release". If the answer comes from the URL itself, `trustDomains` is right; if it comes from hope, use `bypassDomains`.
|
|
190
209
|
|
|
191
210
|
**Or accept it and silence the warning** with `bypassDomains`. Third-party analytics and widget scripts are usually this case — they are built to auto-update, and there is nothing to pin:
|
|
192
211
|
|
|
@@ -232,7 +251,7 @@ There are three places a Vite plugin can compute SRI hashes, and they are not eq
|
|
|
232
251
|
|
|
233
252
|
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.
|
|
234
253
|
|
|
235
|
-
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.
|
|
254
|
+
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. See [Differences from vite-plugin-sri3](#differences-from-vite-plugin-sri3).
|
|
236
255
|
|
|
237
256
|
## Example Project
|
|
238
257
|
|
|
@@ -319,6 +338,20 @@ This will show:
|
|
|
319
338
|
- Missing asset warnings
|
|
320
339
|
- Bundle-key fallback matches (when a URL is resolved via suffix match)
|
|
321
340
|
|
|
341
|
+
## Testing
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
npm test # 174 tests
|
|
345
|
+
npm run test:coverage # the same, with coverage thresholds enforced at 100%
|
|
346
|
+
npm run test:mutation # Stryker, ~4 minutes
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Coverage is held at **100%** of statements, branches, functions and lines, enforced by thresholds in `vitest.config.js` — an uncovered path fails the run rather than quietly lowering the number.
|
|
350
|
+
|
|
351
|
+
Coverage only proves a line ran. Mutation testing changes the code and checks whether a test notices, which is a much harder bar: at 100% coverage this suite still let 162 mutants through on the first run. The gaps it found were real — `no-store` and the qualified `private="…"` / `no-cache="…"` forms were never exercised, the `publicDir` path-traversal guard had no test, `base: './'` and `base: ''` were unreachable from any test, and the immutability tests all used `max-age=31536000, immutable`, where **both** passing conditions hold at once, so neither was actually pinned.
|
|
352
|
+
|
|
353
|
+
The mutation score is **81.19%**, with a break threshold of 80. The remaining survivors are mostly equivalent mutants — warning message wording, cache clearing that has no observable effect, and `typeof source === 'string' ? source : Buffer.from(source)`, whose two branches hash identically. Killing those would mean asserting log text verbatim, which costs more than it protects.
|
|
354
|
+
|
|
322
355
|
## Contributing
|
|
323
356
|
|
|
324
357
|
We welcome contributions! Here's how you can help:
|
|
@@ -335,9 +368,31 @@ Please make sure to:
|
|
|
335
368
|
- Follow the existing code style
|
|
336
369
|
- Update the CHANGELOG.md
|
|
337
370
|
|
|
338
|
-
##
|
|
371
|
+
## Differences from vite-plugin-sri3
|
|
372
|
+
|
|
373
|
+
This plugin began as a fork of [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3) and the two have since diverged. Compared against sri3 `2.0.0`:
|
|
339
374
|
|
|
340
|
-
|
|
375
|
+
| | sri3 2.0.0 | sri4 5.1.0 |
|
|
376
|
+
|---|---|---|
|
|
377
|
+
| Vite range | `^3 ‖ ^4 ‖ ^5 ‖ ^6 ‖ ^7 ‖ ^8` | `^6.4 ‖ ^7 ‖ ^8` |
|
|
378
|
+
| Bundle outputs | ✅ | ✅ |
|
|
379
|
+
| `publicDir` assets | ✅ | ✅ |
|
|
380
|
+
| `skip-sri` per-tag opt-out | ✅ | ✅ |
|
|
381
|
+
| TypeScript definitions | ✅ | ✅ |
|
|
382
|
+
| Hash algorithm | `sha384`, fixed | `sha256` / `sha384` / `sha512`, validated at startup |
|
|
383
|
+
| `crossorigin` attribute | not injected | injected, `anonymous` or `use-credentials` |
|
|
384
|
+
| External resources | fetched unconditionally | gated on reachability, CORS and immutability |
|
|
385
|
+
| Timeout / retry / cache on those fetches | ❌ | ✅ |
|
|
386
|
+
| `bypassDomains` / `trustDomains` | ❌ | ✅ |
|
|
387
|
+
| Hash drift detection | ❌ | re-hashed in `writeBundle`, build fails on drift |
|
|
388
|
+
| `import()`-loaded routes, SSR | ❌ | `importmap` and `manifest` options |
|
|
389
|
+
| Hook ordering | monkey-patches Vite's `generateBundle` | repositions itself in `config.plugins` |
|
|
390
|
+
|
|
391
|
+
**Where sri3 is the better fit:** it supports Vite 3 through 5, which this plugin dropped. If you are on an older Vite, it is the only one of the two that works.
|
|
392
|
+
|
|
393
|
+
**The difference that matters most:** sri3 injects `integrity` without `crossorigin`. SRI on a cross-origin resource requires CORS, so a browser blocks a cross-origin `<script>` or `<link>` that carries `integrity` and no `crossorigin` — which makes sri3's external-resource support difficult to use for the case SRI is usually reached for. That gap is what most of the column above grew out of: injecting `crossorigin` means the CORS response has to be checked at build time, and checking it exposed everything else worth checking.
|
|
394
|
+
|
|
395
|
+
## Inspiration
|
|
341
396
|
|
|
342
397
|
Other projects that influenced this work:
|
|
343
398
|
- [rollup-plugin-sri](https://github.com/JonasKruckenberg/rollup-plugin-sri)
|
package/dist/index.cjs
CHANGED
|
@@ -49,20 +49,14 @@ class ResourceCache {
|
|
|
49
49
|
*/
|
|
50
50
|
class CacheManager {
|
|
51
51
|
constructor() {
|
|
52
|
-
this.urlSupportCache = new ResourceCache();
|
|
53
52
|
this.resourceCache = new ResourceCache();
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
getUrlSupportCache() {
|
|
57
|
-
return this.urlSupportCache
|
|
58
|
-
}
|
|
59
|
-
|
|
60
55
|
getResourceCache() {
|
|
61
56
|
return this.resourceCache
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
clearAll() {
|
|
65
|
-
this.urlSupportCache.clear();
|
|
66
60
|
this.resourceCache.clear();
|
|
67
61
|
}
|
|
68
62
|
}
|
|
@@ -75,7 +69,7 @@ const DEFAULT_TIMEOUT = 5000;
|
|
|
75
69
|
* Does an external URL's host match one of `domains`, or a subdomain of one?
|
|
76
70
|
* Used by both `bypassDomains` and `trustDomains`.
|
|
77
71
|
*/
|
|
78
|
-
function matchesDomain(url, domains = [], logger
|
|
72
|
+
function matchesDomain(url, domains = [], logger) {
|
|
79
73
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
80
74
|
if (domains.length === 0) return false
|
|
81
75
|
|
|
@@ -85,9 +79,7 @@ function matchesDomain(url, domains = [], logger = null) {
|
|
|
85
79
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
86
80
|
)
|
|
87
81
|
} catch (error) {
|
|
88
|
-
|
|
89
|
-
logger.warn(`Invalid URL: ${url}`, error);
|
|
90
|
-
}
|
|
82
|
+
logger.warn(`Invalid URL: ${url}`, error);
|
|
91
83
|
return false
|
|
92
84
|
}
|
|
93
85
|
}
|
|
@@ -165,35 +157,48 @@ function isImmutableResponse(cacheControl) {
|
|
|
165
157
|
/**
|
|
166
158
|
* Resource check with retry mechanism
|
|
167
159
|
*/
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Fetch an external resource and return its bytes, or null if it must not be
|
|
162
|
+
* hashed. The reason is always logged - a tag that silently ships without
|
|
163
|
+
* integrity is the thing that is easy to miss.
|
|
164
|
+
*
|
|
165
|
+
* One GET, not a HEAD probe followed by a GET. The headers the gates need
|
|
166
|
+
* arrive on the response that carries the bytes anyway, so probing separately
|
|
167
|
+
* doubled the requests and threw the useful copy away - and made the plugin
|
|
168
|
+
* depend on HEAD being served at all. It often is not: js.tappaysdk.com
|
|
169
|
+
* answers 403 to HEAD and 200 to GET, which used to read as "could not be
|
|
170
|
+
* checked" on a payment SDK, exactly the kind of script SRI is for.
|
|
171
|
+
*
|
|
172
|
+
* The cost is that a rejected resource is downloaded before it is rejected.
|
|
173
|
+
* That is the right side to lose on: the accepted case, which is every build
|
|
174
|
+
* that actually ships hashes, goes from two requests to one.
|
|
175
|
+
*/
|
|
176
|
+
async function fetchVerifiedResource(url, resourceCache, logger, trusted = false, retries = 1) {
|
|
177
|
+
if (resourceCache.has(url)) {
|
|
178
|
+
return resourceCache.get(url)
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
const reject = (message) => {
|
|
182
|
+
logger.warn(message);
|
|
183
|
+
resourceCache.set(url, null);
|
|
184
|
+
return null
|
|
185
|
+
};
|
|
186
|
+
|
|
173
187
|
let lastError;
|
|
174
188
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
175
189
|
try {
|
|
176
190
|
const controller = new AbortController();
|
|
177
191
|
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
178
192
|
|
|
179
|
-
const response = await fetch(url, {
|
|
180
|
-
method: 'HEAD',
|
|
181
|
-
signal: controller.signal
|
|
182
|
-
});
|
|
193
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
183
194
|
|
|
184
195
|
clearTimeout(timeoutId);
|
|
185
196
|
|
|
186
|
-
// Every path out of here that skips a resource says why. A tag that
|
|
187
|
-
// silently ships without integrity is the thing that is easy to miss.
|
|
188
197
|
if (!response.ok) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
urlSupportCache.set(url, false);
|
|
196
|
-
return false
|
|
198
|
+
return reject(
|
|
199
|
+
`Skipping SRI for ${url}: the server answered ${response.status}. ` +
|
|
200
|
+
'Add the domain to bypassDomains to silence this.'
|
|
201
|
+
)
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
// Only `*` can be verified at build time. Injecting integrity also means
|
|
@@ -202,16 +207,12 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
|
|
|
202
207
|
// served from, that turns a working script into a blocked one.
|
|
203
208
|
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
204
209
|
if (corsHeader !== '*') {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
urlSupportCache.set(url, false);
|
|
214
|
-
return false
|
|
210
|
+
return reject(
|
|
211
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
|
|
212
|
+
`${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
|
|
213
|
+
'cannot be verified at build time. ' +
|
|
214
|
+
'Add the domain to bypassDomains to silence this.'
|
|
215
|
+
)
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
// Reachable and CORS-eligible is not the same property as byte-stable.
|
|
@@ -228,65 +229,14 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
|
|
|
228
229
|
// so a vary-based gate would let that resource straight through.
|
|
229
230
|
const cacheControl = response.headers.get('cache-control');
|
|
230
231
|
if (!trusted && !isImmutableResponse(cacheControl)) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
urlSupportCache.set(url, false);
|
|
242
|
-
return false
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
urlSupportCache.set(url, true);
|
|
246
|
-
return true
|
|
247
|
-
} catch (error) {
|
|
248
|
-
lastError = error;
|
|
249
|
-
if (error.name === 'AbortError') {
|
|
250
|
-
if (logger) {
|
|
251
|
-
logger.warn(`Resource check timed out: ${url}`);
|
|
252
|
-
}
|
|
253
|
-
break // Don't retry timeouts
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Don't wait after the last failed attempt
|
|
257
|
-
if (attempt < retries) {
|
|
258
|
-
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (logger) {
|
|
264
|
-
logger.warn(`Failed to check resource support: ${url}`, lastError);
|
|
265
|
-
}
|
|
266
|
-
urlSupportCache.set(url, false);
|
|
267
|
-
return false
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Optimized resource fetching function with retry mechanism and caching
|
|
272
|
-
*/
|
|
273
|
-
async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
274
|
-
// Check cache
|
|
275
|
-
if (resourceCache.has(url)) {
|
|
276
|
-
return resourceCache.get(url)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
let lastError;
|
|
280
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
281
|
-
try {
|
|
282
|
-
const controller = new AbortController();
|
|
283
|
-
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
284
|
-
|
|
285
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
286
|
-
clearTimeout(timeoutId);
|
|
287
|
-
|
|
288
|
-
if (!response.ok) {
|
|
289
|
-
throw new Error(`HTTP error! status: ${response.status}`)
|
|
232
|
+
return reject(
|
|
233
|
+
`Skipping SRI for ${url}: Cache-Control is ` +
|
|
234
|
+
`${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
|
|
235
|
+
'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
|
|
236
|
+
'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
|
|
237
|
+
'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
|
|
238
|
+
"vendor's rolling URL ships a page that breaks on their next deploy."
|
|
239
|
+
)
|
|
290
240
|
}
|
|
291
241
|
|
|
292
242
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
@@ -295,21 +245,18 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
295
245
|
} catch (error) {
|
|
296
246
|
lastError = error;
|
|
297
247
|
if (error.name === 'AbortError') {
|
|
298
|
-
|
|
299
|
-
logger.warn(`Resource fetch timed out: ${url}`);
|
|
300
|
-
}
|
|
301
|
-
break // Don't retry timeouts
|
|
248
|
+
return reject(`Skipping SRI for ${url}: the request timed out.`)
|
|
302
249
|
}
|
|
303
250
|
|
|
251
|
+
// Don't wait after the last failed attempt
|
|
304
252
|
if (attempt < retries) {
|
|
305
253
|
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
306
254
|
}
|
|
307
255
|
}
|
|
308
256
|
}
|
|
309
257
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
258
|
+
logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
|
|
259
|
+
resourceCache.set(url, null);
|
|
313
260
|
return null
|
|
314
261
|
}
|
|
315
262
|
|
|
@@ -382,14 +329,14 @@ function getBundleKey(htmlPath, url, config) {
|
|
|
382
329
|
* `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
|
|
383
330
|
* but wrong hash, which the browser rejects with no build-time error.
|
|
384
331
|
*/
|
|
385
|
-
function findBundleKey(bundle, bundleKey, logger
|
|
332
|
+
function findBundleKey(bundle, bundleKey, logger) {
|
|
386
333
|
const candidates = Object.keys(bundle).filter(key =>
|
|
387
334
|
key === bundleKey ||
|
|
388
335
|
key.endsWith(`/${bundleKey}`) ||
|
|
389
336
|
bundleKey.endsWith(`/${key}`)
|
|
390
337
|
);
|
|
391
338
|
|
|
392
|
-
if (candidates.length > 1
|
|
339
|
+
if (candidates.length > 1) {
|
|
393
340
|
logger.warn(
|
|
394
341
|
`Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
|
|
395
342
|
);
|
|
@@ -423,17 +370,13 @@ async function readPublicAsset(config, bundleKey, logger) {
|
|
|
423
370
|
// A URL must never reach outside publicDir, however it is spelled
|
|
424
371
|
const relative = path.relative(publicDir, filePath);
|
|
425
372
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
426
|
-
|
|
427
|
-
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
428
|
-
}
|
|
373
|
+
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
429
374
|
return null
|
|
430
375
|
}
|
|
431
376
|
|
|
432
377
|
try {
|
|
433
378
|
const source = await promises.readFile(filePath);
|
|
434
|
-
|
|
435
|
-
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
436
|
-
}
|
|
379
|
+
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
437
380
|
return source
|
|
438
381
|
} catch {
|
|
439
382
|
return null
|
|
@@ -450,7 +393,7 @@ async function calculateIntegrity(
|
|
|
450
393
|
options,
|
|
451
394
|
config,
|
|
452
395
|
cacheManager,
|
|
453
|
-
logger
|
|
396
|
+
logger
|
|
454
397
|
) {
|
|
455
398
|
const {
|
|
456
399
|
ignoreMissingAsset,
|
|
@@ -481,17 +424,13 @@ async function calculateIntegrity(
|
|
|
481
424
|
let bundleFileName = null;
|
|
482
425
|
if (fetchUrl) {
|
|
483
426
|
const trusted = matchesDomain(fetchUrl, trustDomains, logger);
|
|
484
|
-
|
|
485
|
-
fetchUrl, cacheManager.
|
|
427
|
+
source = await fetchVerifiedResource(
|
|
428
|
+
fetchUrl, cacheManager.getResourceCache(), logger, trusted
|
|
486
429
|
);
|
|
487
|
-
if (!isSupported) return null
|
|
488
|
-
source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
|
|
489
430
|
if (!source) return null
|
|
490
431
|
} else if (!ownAsset && SCHEME_RE.test(url)) {
|
|
491
432
|
// data:/blob: and unknown schemes cannot be resolved to a bundle asset
|
|
492
|
-
|
|
493
|
-
logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
|
|
494
|
-
}
|
|
433
|
+
logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
|
|
495
434
|
return null
|
|
496
435
|
} else {
|
|
497
436
|
const bundleKey = getBundleKey(htmlPath, url, config);
|
|
@@ -504,9 +443,7 @@ async function calculateIntegrity(
|
|
|
504
443
|
const possibleMatch = findBundleKey(bundle, bundleKey, logger);
|
|
505
444
|
|
|
506
445
|
if (possibleMatch) {
|
|
507
|
-
|
|
508
|
-
logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
|
|
509
|
-
}
|
|
446
|
+
logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
|
|
510
447
|
bundleFileName = possibleMatch;
|
|
511
448
|
source = bundleSource(bundle[possibleMatch]);
|
|
512
449
|
} else {
|
|
@@ -515,11 +452,9 @@ async function calculateIntegrity(
|
|
|
515
452
|
|
|
516
453
|
if (!source) {
|
|
517
454
|
if (ignoreMissingAsset) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
);
|
|
522
|
-
}
|
|
455
|
+
logger.warn(
|
|
456
|
+
`Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
|
|
457
|
+
);
|
|
523
458
|
return null
|
|
524
459
|
}
|
|
525
460
|
throw new Error(
|
|
@@ -570,7 +505,9 @@ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
|
|
|
570
505
|
function getAttr(tag, re) {
|
|
571
506
|
const match = tag.match(re);
|
|
572
507
|
if (!match) return null
|
|
573
|
-
|
|
508
|
+
// One of the three alternatives matched or the regex would not have, and an
|
|
509
|
+
// empty value is a string rather than undefined - so there is no fourth case.
|
|
510
|
+
return match[1] ?? match[2] ?? match[3]
|
|
574
511
|
}
|
|
575
512
|
|
|
576
513
|
const HTML_PATTERNS = {
|
|
@@ -822,9 +759,7 @@ async function transformHTML(
|
|
|
822
759
|
function createTransformer(options, config, cacheManager, logger) {
|
|
823
760
|
return {
|
|
824
761
|
transformHTML: (bundle, htmlPath, html) =>
|
|
825
|
-
transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
|
|
826
|
-
calculateIntegrity: (bundle, htmlPath, url) =>
|
|
827
|
-
calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
|
|
762
|
+
transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
|
|
828
763
|
}
|
|
829
764
|
}
|
|
830
765
|
|
|
@@ -895,13 +830,6 @@ class Logger {
|
|
|
895
830
|
console.debug(...this.formatMessage(message, ...args));
|
|
896
831
|
}
|
|
897
832
|
}
|
|
898
|
-
|
|
899
|
-
/**
|
|
900
|
-
* Create a child logger with the same configuration
|
|
901
|
-
*/
|
|
902
|
-
child(name) {
|
|
903
|
-
return new Logger(this.logLevel, `${this.pluginName}:${name}`)
|
|
904
|
-
}
|
|
905
833
|
}
|
|
906
834
|
|
|
907
835
|
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
|
package/dist/index.js
CHANGED
|
@@ -45,20 +45,14 @@ class ResourceCache {
|
|
|
45
45
|
*/
|
|
46
46
|
class CacheManager {
|
|
47
47
|
constructor() {
|
|
48
|
-
this.urlSupportCache = new ResourceCache();
|
|
49
48
|
this.resourceCache = new ResourceCache();
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
getUrlSupportCache() {
|
|
53
|
-
return this.urlSupportCache
|
|
54
|
-
}
|
|
55
|
-
|
|
56
51
|
getResourceCache() {
|
|
57
52
|
return this.resourceCache
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
clearAll() {
|
|
61
|
-
this.urlSupportCache.clear();
|
|
62
56
|
this.resourceCache.clear();
|
|
63
57
|
}
|
|
64
58
|
}
|
|
@@ -71,7 +65,7 @@ const DEFAULT_TIMEOUT = 5000;
|
|
|
71
65
|
* Does an external URL's host match one of `domains`, or a subdomain of one?
|
|
72
66
|
* Used by both `bypassDomains` and `trustDomains`.
|
|
73
67
|
*/
|
|
74
|
-
function matchesDomain(url, domains = [], logger
|
|
68
|
+
function matchesDomain(url, domains = [], logger) {
|
|
75
69
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
76
70
|
if (domains.length === 0) return false
|
|
77
71
|
|
|
@@ -81,9 +75,7 @@ function matchesDomain(url, domains = [], logger = null) {
|
|
|
81
75
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
82
76
|
)
|
|
83
77
|
} catch (error) {
|
|
84
|
-
|
|
85
|
-
logger.warn(`Invalid URL: ${url}`, error);
|
|
86
|
-
}
|
|
78
|
+
logger.warn(`Invalid URL: ${url}`, error);
|
|
87
79
|
return false
|
|
88
80
|
}
|
|
89
81
|
}
|
|
@@ -161,35 +153,48 @@ function isImmutableResponse(cacheControl) {
|
|
|
161
153
|
/**
|
|
162
154
|
* Resource check with retry mechanism
|
|
163
155
|
*/
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Fetch an external resource and return its bytes, or null if it must not be
|
|
158
|
+
* hashed. The reason is always logged - a tag that silently ships without
|
|
159
|
+
* integrity is the thing that is easy to miss.
|
|
160
|
+
*
|
|
161
|
+
* One GET, not a HEAD probe followed by a GET. The headers the gates need
|
|
162
|
+
* arrive on the response that carries the bytes anyway, so probing separately
|
|
163
|
+
* doubled the requests and threw the useful copy away - and made the plugin
|
|
164
|
+
* depend on HEAD being served at all. It often is not: js.tappaysdk.com
|
|
165
|
+
* answers 403 to HEAD and 200 to GET, which used to read as "could not be
|
|
166
|
+
* checked" on a payment SDK, exactly the kind of script SRI is for.
|
|
167
|
+
*
|
|
168
|
+
* The cost is that a rejected resource is downloaded before it is rejected.
|
|
169
|
+
* That is the right side to lose on: the accepted case, which is every build
|
|
170
|
+
* that actually ships hashes, goes from two requests to one.
|
|
171
|
+
*/
|
|
172
|
+
async function fetchVerifiedResource(url, resourceCache, logger, trusted = false, retries = 1) {
|
|
173
|
+
if (resourceCache.has(url)) {
|
|
174
|
+
return resourceCache.get(url)
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
const reject = (message) => {
|
|
178
|
+
logger.warn(message);
|
|
179
|
+
resourceCache.set(url, null);
|
|
180
|
+
return null
|
|
181
|
+
};
|
|
182
|
+
|
|
169
183
|
let lastError;
|
|
170
184
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
171
185
|
try {
|
|
172
186
|
const controller = new AbortController();
|
|
173
187
|
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
174
188
|
|
|
175
|
-
const response = await fetch(url, {
|
|
176
|
-
method: 'HEAD',
|
|
177
|
-
signal: controller.signal
|
|
178
|
-
});
|
|
189
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
179
190
|
|
|
180
191
|
clearTimeout(timeoutId);
|
|
181
192
|
|
|
182
|
-
// Every path out of here that skips a resource says why. A tag that
|
|
183
|
-
// silently ships without integrity is the thing that is easy to miss.
|
|
184
193
|
if (!response.ok) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
urlSupportCache.set(url, false);
|
|
192
|
-
return false
|
|
194
|
+
return reject(
|
|
195
|
+
`Skipping SRI for ${url}: the server answered ${response.status}. ` +
|
|
196
|
+
'Add the domain to bypassDomains to silence this.'
|
|
197
|
+
)
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
// Only `*` can be verified at build time. Injecting integrity also means
|
|
@@ -198,16 +203,12 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
|
|
|
198
203
|
// served from, that turns a working script into a blocked one.
|
|
199
204
|
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
200
205
|
if (corsHeader !== '*') {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
urlSupportCache.set(url, false);
|
|
210
|
-
return false
|
|
206
|
+
return reject(
|
|
207
|
+
`Skipping SRI for ${url}: Access-Control-Allow-Origin is ` +
|
|
208
|
+
`${corsHeader ? `"${corsHeader}", not "*"` : 'absent'}, so crossorigin="anonymous" ` +
|
|
209
|
+
'cannot be verified at build time. ' +
|
|
210
|
+
'Add the domain to bypassDomains to silence this.'
|
|
211
|
+
)
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
// Reachable and CORS-eligible is not the same property as byte-stable.
|
|
@@ -224,65 +225,14 @@ async function checkResourceSupport(url, urlSupportCache, logger = null, trusted
|
|
|
224
225
|
// so a vary-based gate would let that resource straight through.
|
|
225
226
|
const cacheControl = response.headers.get('cache-control');
|
|
226
227
|
if (!trusted && !isImmutableResponse(cacheControl)) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
urlSupportCache.set(url, false);
|
|
238
|
-
return false
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
urlSupportCache.set(url, true);
|
|
242
|
-
return true
|
|
243
|
-
} catch (error) {
|
|
244
|
-
lastError = error;
|
|
245
|
-
if (error.name === 'AbortError') {
|
|
246
|
-
if (logger) {
|
|
247
|
-
logger.warn(`Resource check timed out: ${url}`);
|
|
248
|
-
}
|
|
249
|
-
break // Don't retry timeouts
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Don't wait after the last failed attempt
|
|
253
|
-
if (attempt < retries) {
|
|
254
|
-
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (logger) {
|
|
260
|
-
logger.warn(`Failed to check resource support: ${url}`, lastError);
|
|
261
|
-
}
|
|
262
|
-
urlSupportCache.set(url, false);
|
|
263
|
-
return false
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Optimized resource fetching function with retry mechanism and caching
|
|
268
|
-
*/
|
|
269
|
-
async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
270
|
-
// Check cache
|
|
271
|
-
if (resourceCache.has(url)) {
|
|
272
|
-
return resourceCache.get(url)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
let lastError;
|
|
276
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
277
|
-
try {
|
|
278
|
-
const controller = new AbortController();
|
|
279
|
-
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
280
|
-
|
|
281
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
282
|
-
clearTimeout(timeoutId);
|
|
283
|
-
|
|
284
|
-
if (!response.ok) {
|
|
285
|
-
throw new Error(`HTTP error! status: ${response.status}`)
|
|
228
|
+
return reject(
|
|
229
|
+
`Skipping SRI for ${url}: Cache-Control is ` +
|
|
230
|
+
`${cacheControl ? `"${cacheControl}"` : 'absent'}, so the origin does not declare ` +
|
|
231
|
+
'this URL immutable and its bytes may differ from the ones hashed here. Pin a ' +
|
|
232
|
+
'version in the URL, or add the domain to bypassDomains to accept it unprotected. ' +
|
|
233
|
+
'Only reach for trustDomains on a host you control - forcing a hash onto a ' +
|
|
234
|
+
"vendor's rolling URL ships a page that breaks on their next deploy."
|
|
235
|
+
)
|
|
286
236
|
}
|
|
287
237
|
|
|
288
238
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
@@ -291,21 +241,18 @@ async function fetchResource(url, resourceCache, logger = null, retries = 1) {
|
|
|
291
241
|
} catch (error) {
|
|
292
242
|
lastError = error;
|
|
293
243
|
if (error.name === 'AbortError') {
|
|
294
|
-
|
|
295
|
-
logger.warn(`Resource fetch timed out: ${url}`);
|
|
296
|
-
}
|
|
297
|
-
break // Don't retry timeouts
|
|
244
|
+
return reject(`Skipping SRI for ${url}: the request timed out.`)
|
|
298
245
|
}
|
|
299
246
|
|
|
247
|
+
// Don't wait after the last failed attempt
|
|
300
248
|
if (attempt < retries) {
|
|
301
249
|
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
302
250
|
}
|
|
303
251
|
}
|
|
304
252
|
}
|
|
305
253
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
254
|
+
logger.warn(`Skipping SRI for ${url}: the request failed.`, lastError);
|
|
255
|
+
resourceCache.set(url, null);
|
|
309
256
|
return null
|
|
310
257
|
}
|
|
311
258
|
|
|
@@ -378,14 +325,14 @@ function getBundleKey(htmlPath, url, config) {
|
|
|
378
325
|
* `assets/vendor-main.js` - a cross-filename match would inject a valid-looking
|
|
379
326
|
* but wrong hash, which the browser rejects with no build-time error.
|
|
380
327
|
*/
|
|
381
|
-
function findBundleKey(bundle, bundleKey, logger
|
|
328
|
+
function findBundleKey(bundle, bundleKey, logger) {
|
|
382
329
|
const candidates = Object.keys(bundle).filter(key =>
|
|
383
330
|
key === bundleKey ||
|
|
384
331
|
key.endsWith(`/${bundleKey}`) ||
|
|
385
332
|
bundleKey.endsWith(`/${key}`)
|
|
386
333
|
);
|
|
387
334
|
|
|
388
|
-
if (candidates.length > 1
|
|
335
|
+
if (candidates.length > 1) {
|
|
389
336
|
logger.warn(
|
|
390
337
|
`Ambiguous bundle key for "${bundleKey}": ${candidates.join(', ')} - using ${candidates[0]}`
|
|
391
338
|
);
|
|
@@ -419,17 +366,13 @@ async function readPublicAsset(config, bundleKey, logger) {
|
|
|
419
366
|
// A URL must never reach outside publicDir, however it is spelled
|
|
420
367
|
const relative = path.relative(publicDir, filePath);
|
|
421
368
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
422
|
-
|
|
423
|
-
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
424
|
-
}
|
|
369
|
+
logger.warn(`Refusing to read outside publicDir: ${bundleKey}`);
|
|
425
370
|
return null
|
|
426
371
|
}
|
|
427
372
|
|
|
428
373
|
try {
|
|
429
374
|
const source = await readFile(filePath);
|
|
430
|
-
|
|
431
|
-
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
432
|
-
}
|
|
375
|
+
logger.debug(`Resolved from publicDir: ${bundleKey}`);
|
|
433
376
|
return source
|
|
434
377
|
} catch {
|
|
435
378
|
return null
|
|
@@ -446,7 +389,7 @@ async function calculateIntegrity(
|
|
|
446
389
|
options,
|
|
447
390
|
config,
|
|
448
391
|
cacheManager,
|
|
449
|
-
logger
|
|
392
|
+
logger
|
|
450
393
|
) {
|
|
451
394
|
const {
|
|
452
395
|
ignoreMissingAsset,
|
|
@@ -477,17 +420,13 @@ async function calculateIntegrity(
|
|
|
477
420
|
let bundleFileName = null;
|
|
478
421
|
if (fetchUrl) {
|
|
479
422
|
const trusted = matchesDomain(fetchUrl, trustDomains, logger);
|
|
480
|
-
|
|
481
|
-
fetchUrl, cacheManager.
|
|
423
|
+
source = await fetchVerifiedResource(
|
|
424
|
+
fetchUrl, cacheManager.getResourceCache(), logger, trusted
|
|
482
425
|
);
|
|
483
|
-
if (!isSupported) return null
|
|
484
|
-
source = await fetchResource(fetchUrl, cacheManager.getResourceCache(), logger);
|
|
485
426
|
if (!source) return null
|
|
486
427
|
} else if (!ownAsset && SCHEME_RE.test(url)) {
|
|
487
428
|
// data:/blob: and unknown schemes cannot be resolved to a bundle asset
|
|
488
|
-
|
|
489
|
-
logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
|
|
490
|
-
}
|
|
429
|
+
logger.debug(`Skipping URL that is not a bundle asset: ${url}`);
|
|
491
430
|
return null
|
|
492
431
|
} else {
|
|
493
432
|
const bundleKey = getBundleKey(htmlPath, url, config);
|
|
@@ -500,9 +439,7 @@ async function calculateIntegrity(
|
|
|
500
439
|
const possibleMatch = findBundleKey(bundle, bundleKey, logger);
|
|
501
440
|
|
|
502
441
|
if (possibleMatch) {
|
|
503
|
-
|
|
504
|
-
logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
|
|
505
|
-
}
|
|
442
|
+
logger.debug(`Bundle key fallback: ${bundleKey} -> ${possibleMatch}`);
|
|
506
443
|
bundleFileName = possibleMatch;
|
|
507
444
|
source = bundleSource(bundle[possibleMatch]);
|
|
508
445
|
} else {
|
|
@@ -511,11 +448,9 @@ async function calculateIntegrity(
|
|
|
511
448
|
|
|
512
449
|
if (!source) {
|
|
513
450
|
if (ignoreMissingAsset) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
);
|
|
518
|
-
}
|
|
451
|
+
logger.warn(
|
|
452
|
+
`Asset not found in bundle or publicDir: ${url} (path: ${htmlPath}, key: ${bundleKey})`
|
|
453
|
+
);
|
|
519
454
|
return null
|
|
520
455
|
}
|
|
521
456
|
throw new Error(
|
|
@@ -566,7 +501,9 @@ const SRI_LINK_RELS = new Set(['stylesheet', 'modulepreload']);
|
|
|
566
501
|
function getAttr(tag, re) {
|
|
567
502
|
const match = tag.match(re);
|
|
568
503
|
if (!match) return null
|
|
569
|
-
|
|
504
|
+
// One of the three alternatives matched or the regex would not have, and an
|
|
505
|
+
// empty value is a string rather than undefined - so there is no fourth case.
|
|
506
|
+
return match[1] ?? match[2] ?? match[3]
|
|
570
507
|
}
|
|
571
508
|
|
|
572
509
|
const HTML_PATTERNS = {
|
|
@@ -818,9 +755,7 @@ async function transformHTML(
|
|
|
818
755
|
function createTransformer(options, config, cacheManager, logger) {
|
|
819
756
|
return {
|
|
820
757
|
transformHTML: (bundle, htmlPath, html) =>
|
|
821
|
-
transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
|
|
822
|
-
calculateIntegrity: (bundle, htmlPath, url) =>
|
|
823
|
-
calculateIntegrity(bundle, htmlPath, url, options, config, cacheManager, logger)
|
|
758
|
+
transformHTML(bundle, htmlPath, html, options, config, cacheManager, logger)
|
|
824
759
|
}
|
|
825
760
|
}
|
|
826
761
|
|
|
@@ -891,13 +826,6 @@ class Logger {
|
|
|
891
826
|
console.debug(...this.formatMessage(message, ...args));
|
|
892
827
|
}
|
|
893
828
|
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Create a child logger with the same configuration
|
|
897
|
-
*/
|
|
898
|
-
child(name) {
|
|
899
|
-
return new Logger(this.logLevel, `${this.pluginName}:${name}`)
|
|
900
|
-
}
|
|
901
829
|
}
|
|
902
830
|
|
|
903
831
|
// Vite 6/7 uses `vite:build-import-analysis`; Vite 8 Rolldown native path adds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-sri4",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.1",
|
|
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",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:watch": "vitest",
|
|
26
26
|
"test:coverage": "vitest run --coverage",
|
|
27
|
-
"prepublishOnly": "npm run build"
|
|
27
|
+
"prepublishOnly": "npm run build",
|
|
28
|
+
"test:mutation": "stryker run"
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
31
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@rollup/plugin-commonjs": "^29.0.3",
|
|
34
35
|
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
36
|
+
"@stryker-mutator/core": "^10.0.0",
|
|
37
|
+
"@stryker-mutator/vitest-runner": "^10.0.0",
|
|
35
38
|
"@vitest/coverage-v8": "^5.0.0",
|
|
36
39
|
"oxlint": "^1.82.0",
|
|
37
40
|
"rollup": "^4.63.1",
|