vite-plugin-sri4 1.9.0 → 3.0.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/README.md +10 -0
- package/dist/index.cjs +264 -55
- package/dist/index.js +262 -56
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
16
16
|
- [Best Practices](#best-practices)
|
|
17
17
|
- [Troubleshooting](#troubleshooting)
|
|
18
18
|
- [Contributing](#contributing)
|
|
19
|
+
- [Inspiration](#inspiration)
|
|
19
20
|
- [License](#license)
|
|
20
21
|
|
|
21
22
|
## Features
|
|
@@ -26,6 +27,7 @@ A Vite plugin to generate Subresource Integrity (SRI) hashes for your assets dur
|
|
|
26
27
|
- **Bypass Domains:** Option to specify domains to bypass SRI injection.
|
|
27
28
|
- **Missing Asset Handling:** Configurable warning suppression for missing assets.
|
|
28
29
|
- **Robust Content Support:** Handles various content types including strings, Buffer, and Uint8Array.
|
|
30
|
+
- **Vite Compatibility:** Compatible with Vite 6.0 and 7.0.
|
|
29
31
|
|
|
30
32
|
## Installation
|
|
31
33
|
|
|
@@ -178,6 +180,14 @@ Please make sure to:
|
|
|
178
180
|
- Follow the existing code style
|
|
179
181
|
- Update the CHANGELOG.md
|
|
180
182
|
|
|
183
|
+
## Inspiration
|
|
184
|
+
|
|
185
|
+
This project was inspired by [vite-plugin-sri3](https://github.com/yoyo930021/vite-plugin-sri3), which provides subresource integrity for Vite. We've built upon its foundation to create an enhanced version with additional features and improved compatibility.
|
|
186
|
+
|
|
187
|
+
Other projects that influenced this work:
|
|
188
|
+
- [rollup-plugin-sri](https://github.com/JonasKruckenberg/rollup-plugin-sri)
|
|
189
|
+
- [@small-tech/vite-plugin-sri](https://github.com/small-tech/vite-plugin-sri)
|
|
190
|
+
|
|
181
191
|
## License
|
|
182
192
|
|
|
183
193
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
package/dist/index.cjs
CHANGED
|
@@ -1,81 +1,197 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
3
5
|
var crypto = require('crypto');
|
|
4
6
|
var path = require('path');
|
|
5
7
|
var fetch = require('cross-fetch');
|
|
6
8
|
|
|
9
|
+
// Constants definition
|
|
7
10
|
const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
|
|
11
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
12
|
+
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
13
|
+
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
14
|
+
|
|
15
|
+
// Optimized regex patterns for better readability and efficiency
|
|
8
16
|
const HTML_PATTERNS = {
|
|
9
17
|
script: {
|
|
10
|
-
regex: /<script[
|
|
18
|
+
regex: /<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>/g,
|
|
11
19
|
endOffset: 10
|
|
12
20
|
},
|
|
13
21
|
stylesheet: {
|
|
14
|
-
regex: /<link[
|
|
22
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
15
23
|
endOffset: 1
|
|
16
24
|
},
|
|
17
25
|
modulepreload: {
|
|
18
|
-
regex: /<link[
|
|
26
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']modulepreload["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
19
27
|
endOffset: 1
|
|
20
28
|
}
|
|
21
29
|
};
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
// Extended caching mechanism with expiration time
|
|
32
|
+
class ResourceCache {
|
|
33
|
+
constructor(ttl = 3600000) { // Default cache for 1 hour
|
|
34
|
+
this.cache = new Map();
|
|
35
|
+
this.ttl = ttl;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(key) {
|
|
39
|
+
const item = this.cache.get(key);
|
|
40
|
+
if (!item) return undefined
|
|
41
|
+
|
|
42
|
+
// Check if expired
|
|
43
|
+
if (Date.now() > item.expiry) {
|
|
44
|
+
this.cache.delete(key);
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return item.value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
set(key, value) {
|
|
52
|
+
this.cache.set(key, {
|
|
53
|
+
value,
|
|
54
|
+
expiry: Date.now() + this.ttl
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
has(key) {
|
|
59
|
+
return this.get(key) !== undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear() {
|
|
63
|
+
this.cache.clear();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const urlSupportCache = new ResourceCache();
|
|
68
|
+
const resourceCache = new ResourceCache();
|
|
24
69
|
|
|
70
|
+
// Check if URL is from a bypass domain
|
|
25
71
|
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
26
|
-
if (!url.startsWith('http')) return false
|
|
72
|
+
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
73
|
+
|
|
27
74
|
try {
|
|
28
75
|
const urlObj = new URL(url);
|
|
29
76
|
return bypassDomains.some(domain =>
|
|
30
77
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
31
78
|
)
|
|
32
|
-
} catch {
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Invalid URL: ${url}`, error);
|
|
33
81
|
return false
|
|
34
82
|
}
|
|
35
83
|
}
|
|
36
84
|
|
|
37
|
-
|
|
85
|
+
// Resource check with retry mechanism
|
|
86
|
+
async function checkResourceSupport(url, retries = 2) {
|
|
38
87
|
if (urlSupportCache.has(url)) {
|
|
39
88
|
return urlSupportCache.get(url)
|
|
40
89
|
}
|
|
41
90
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
91
|
+
let lastError;
|
|
92
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
93
|
+
try {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
96
|
+
|
|
97
|
+
const response = await fetch(url, {
|
|
98
|
+
method: 'HEAD',
|
|
99
|
+
signal: controller.signal
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
|
|
104
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
105
|
+
const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
|
|
106
|
+
urlSupportCache.set(url, isSupported);
|
|
107
|
+
return isSupported
|
|
108
|
+
} catch (error) {
|
|
109
|
+
lastError = error;
|
|
110
|
+
if (error.name === 'AbortError') {
|
|
111
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Resource check timed out: ${url}`);
|
|
112
|
+
break // Don't retry timeouts
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Don't wait after the last failed attempt
|
|
116
|
+
if (attempt < retries) {
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
54
120
|
}
|
|
121
|
+
|
|
122
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to check resource support: ${url}`, lastError);
|
|
123
|
+
urlSupportCache.set(url, false);
|
|
124
|
+
return false
|
|
55
125
|
}
|
|
56
126
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
127
|
+
// Optimized resource fetching function with retry mechanism and caching
|
|
128
|
+
async function fetchResource(url, retries = 1) {
|
|
129
|
+
// Check cache
|
|
130
|
+
if (resourceCache.has(url)) {
|
|
131
|
+
return resourceCache.get(url)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let lastError;
|
|
135
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
139
|
+
|
|
140
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
141
|
+
clearTimeout(timeoutId);
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`HTTP error! status: ${response.status}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
148
|
+
resourceCache.set(url, data);
|
|
149
|
+
return data
|
|
150
|
+
} catch (error) {
|
|
151
|
+
lastError = error;
|
|
152
|
+
if (error.name === 'AbortError') {
|
|
153
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Resource fetch timed out: ${url}`);
|
|
154
|
+
break // Don't retry timeouts
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (attempt < retries) {
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
65
161
|
}
|
|
162
|
+
|
|
163
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to fetch external resource: ${url}`, lastError);
|
|
164
|
+
return null
|
|
66
165
|
}
|
|
67
166
|
|
|
68
167
|
function createTransformer(options, config) {
|
|
69
|
-
const {
|
|
168
|
+
const {
|
|
169
|
+
ignoreMissingAsset,
|
|
170
|
+
bypassDomains,
|
|
171
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM
|
|
172
|
+
} = options;
|
|
70
173
|
|
|
174
|
+
// Improved method for getting bundle keys
|
|
71
175
|
const getBundleKey = (htmlPath, url) => {
|
|
176
|
+
// Handle absolute path URLs
|
|
177
|
+
if (url.startsWith('/')) {
|
|
178
|
+
// Remove leading slash to match keys in bundle
|
|
179
|
+
return url.substring(1)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Handle relative paths (when config.base is relative)
|
|
72
183
|
if (config.base === './' || config.base === '') {
|
|
73
|
-
return path.posix.resolve(htmlPath, url)
|
|
184
|
+
return path.posix.resolve(path.posix.dirname(htmlPath), url)
|
|
74
185
|
}
|
|
75
|
-
|
|
186
|
+
|
|
187
|
+
// Handle other cases, remove base prefix from URL
|
|
188
|
+
return url.startsWith(config.base)
|
|
189
|
+
? url.substring(config.base.length)
|
|
190
|
+
: url
|
|
76
191
|
};
|
|
77
192
|
|
|
78
193
|
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
194
|
+
// Skip specified domains
|
|
79
195
|
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
80
196
|
return null
|
|
81
197
|
}
|
|
@@ -87,41 +203,93 @@ function createTransformer(options, config) {
|
|
|
87
203
|
source = await fetchResource(url);
|
|
88
204
|
if (!source) return null
|
|
89
205
|
} else {
|
|
90
|
-
const
|
|
206
|
+
const bundleKey = getBundleKey(htmlPath, url);
|
|
207
|
+
const bundleItem = bundle[bundleKey];
|
|
208
|
+
|
|
91
209
|
if (!bundleItem) {
|
|
92
|
-
|
|
93
|
-
|
|
210
|
+
// Try to find a matching item with more flexible matching
|
|
211
|
+
const possibleMatch = Object.keys(bundle).find(key =>
|
|
212
|
+
key.endsWith(bundleKey) || bundleKey.endsWith(key)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (possibleMatch) {
|
|
216
|
+
source = bundle[possibleMatch].type === 'chunk'
|
|
217
|
+
? bundle[possibleMatch].code
|
|
218
|
+
: bundle[possibleMatch].source;
|
|
219
|
+
} else if (ignoreMissingAsset) {
|
|
220
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
|
|
221
|
+
return null
|
|
222
|
+
} else {
|
|
223
|
+
throw new Error(`Asset ${url} not found in bundle (path: ${htmlPath}, key: ${bundleKey})`)
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
94
227
|
}
|
|
95
|
-
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
96
228
|
}
|
|
97
229
|
|
|
98
|
-
|
|
230
|
+
// Ensure source is a Uint8Array or string
|
|
231
|
+
if (!source) return null
|
|
232
|
+
|
|
233
|
+
if (typeof source === 'string') {
|
|
234
|
+
return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(Buffer.from(source)).digest('base64')}`
|
|
99
238
|
};
|
|
100
239
|
|
|
101
240
|
const transformHTML = async (bundle, htmlPath, html) => {
|
|
241
|
+
if (!html || typeof html !== 'string') {
|
|
242
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Invalid HTML content for ${htmlPath}`);
|
|
243
|
+
return html
|
|
244
|
+
}
|
|
245
|
+
|
|
102
246
|
const changes = [];
|
|
103
247
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
248
|
+
// Collect changes from all patterns in parallel
|
|
249
|
+
await Promise.all(
|
|
250
|
+
Object.values(HTML_PATTERNS).map(async ({ regex, endOffset }) => {
|
|
251
|
+
const matches = [...html.matchAll(regex)];
|
|
252
|
+
|
|
253
|
+
// Process each match in parallel
|
|
254
|
+
const matchResults = await Promise.all(
|
|
255
|
+
matches.map(async match => {
|
|
256
|
+
const [, url] = match;
|
|
257
|
+
if (!url) return null
|
|
258
|
+
|
|
259
|
+
const end = match.index + match[0].length;
|
|
260
|
+
const integrity = await calculateIntegrity(bundle, htmlPath, url);
|
|
261
|
+
|
|
262
|
+
if (integrity) {
|
|
263
|
+
return {
|
|
264
|
+
integrity,
|
|
265
|
+
position: end - endOffset,
|
|
266
|
+
url // For logging
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Filter out null results
|
|
274
|
+
matchResults.filter(Boolean).forEach(result => changes.push(result));
|
|
275
|
+
})
|
|
276
|
+
);
|
|
119
277
|
|
|
278
|
+
// Sort by position in descending order to insert from back to front (won't affect insertion points ahead)
|
|
120
279
|
changes.sort((a, b) => b.position - a.position);
|
|
121
280
|
|
|
122
|
-
|
|
281
|
+
// Check if identical integrity attributes already exist to avoid duplicates
|
|
282
|
+
for (const { integrity, position, url } of changes) {
|
|
123
283
|
const insertText = ` integrity="${integrity}"`;
|
|
284
|
+
|
|
285
|
+
// Check if integrity attribute already exists
|
|
286
|
+
const segment = html.slice(Math.max(0, position - 100), position + 100);
|
|
287
|
+
if (segment.includes(`integrity="${integrity}"`)) {
|
|
288
|
+
continue // Skip elements that already have the same integrity
|
|
289
|
+
}
|
|
290
|
+
|
|
124
291
|
html = html.slice(0, position) + insertText + html.slice(position);
|
|
292
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] Added integrity for: ${url}`);
|
|
125
293
|
}
|
|
126
294
|
|
|
127
295
|
return html
|
|
@@ -134,13 +302,37 @@ function sri(options = {}) {
|
|
|
134
302
|
const {
|
|
135
303
|
ignoreMissingAsset = false,
|
|
136
304
|
bypassDomains = [],
|
|
137
|
-
hashAlgorithm =
|
|
305
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
306
|
+
logLevel = 'warn'
|
|
138
307
|
} = options;
|
|
139
308
|
|
|
309
|
+
// Adjust log level
|
|
310
|
+
const originalConsoleWarn = console.warn;
|
|
311
|
+
const originalConsoleDebug = console.debug;
|
|
312
|
+
|
|
313
|
+
if (logLevel === 'error') {
|
|
314
|
+
console.warn = () => {};
|
|
315
|
+
console.debug = () => {};
|
|
316
|
+
} else if (logLevel === 'warn') {
|
|
317
|
+
console.debug = () => {};
|
|
318
|
+
}
|
|
319
|
+
|
|
140
320
|
return {
|
|
141
|
-
name:
|
|
321
|
+
name: DEFAULT_PLUGIN_NAME,
|
|
142
322
|
enforce: 'post',
|
|
143
323
|
apply: 'build',
|
|
324
|
+
|
|
325
|
+
// Cleanup work
|
|
326
|
+
buildEnd() {
|
|
327
|
+
// Restore console functions
|
|
328
|
+
console.warn = originalConsoleWarn;
|
|
329
|
+
console.debug = originalConsoleDebug;
|
|
330
|
+
|
|
331
|
+
// Clear caches
|
|
332
|
+
urlSupportCache.clear();
|
|
333
|
+
resourceCache.clear();
|
|
334
|
+
},
|
|
335
|
+
|
|
144
336
|
configResolved(config) {
|
|
145
337
|
const transformer = createTransformer({
|
|
146
338
|
ignoreMissingAsset,
|
|
@@ -155,16 +347,32 @@ function sri(options = {}) {
|
|
|
155
347
|
/\.html?$/.test(chunk.fileName)
|
|
156
348
|
);
|
|
157
349
|
|
|
350
|
+
if (htmlFiles.length === 0) {
|
|
351
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] No HTML files found in bundle`);
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Process all HTML files in parallel
|
|
158
356
|
await Promise.all(
|
|
159
357
|
htmlFiles.map(async ([name, chunk]) => {
|
|
160
|
-
|
|
358
|
+
try {
|
|
359
|
+
const originalContent = chunk.source.toString();
|
|
360
|
+
chunk.source = await transformer.transformHTML(bundle, name, originalContent);
|
|
361
|
+
|
|
362
|
+
if (originalContent !== chunk.source) {
|
|
363
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] SRI attributes added to ${name}`);
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Error processing ${name}:`, error);
|
|
367
|
+
// Keep original content on error
|
|
368
|
+
}
|
|
161
369
|
})
|
|
162
370
|
);
|
|
163
371
|
};
|
|
164
372
|
|
|
165
373
|
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
166
374
|
if (!plugin) {
|
|
167
|
-
throw new Error(
|
|
375
|
+
throw new Error(`[${DEFAULT_PLUGIN_NAME}] requires Vite 2.0.0 or higher`)
|
|
168
376
|
}
|
|
169
377
|
|
|
170
378
|
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
@@ -184,4 +392,5 @@ function sri(options = {}) {
|
|
|
184
392
|
}
|
|
185
393
|
}
|
|
186
394
|
|
|
395
|
+
exports.default = sri;
|
|
187
396
|
exports.sri = sri;
|
package/dist/index.js
CHANGED
|
@@ -2,78 +2,192 @@ import { createHash } from 'crypto';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fetch from 'cross-fetch';
|
|
4
4
|
|
|
5
|
+
// Constants definition
|
|
5
6
|
const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
|
|
7
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
8
|
+
const DEFAULT_HASH_ALGORITHM = 'sha384';
|
|
9
|
+
const DEFAULT_PLUGIN_NAME = 'vite-plugin-sri4';
|
|
10
|
+
|
|
11
|
+
// Optimized regex patterns for better readability and efficiency
|
|
6
12
|
const HTML_PATTERNS = {
|
|
7
13
|
script: {
|
|
8
|
-
regex: /<script[
|
|
14
|
+
regex: /<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>/g,
|
|
9
15
|
endOffset: 10
|
|
10
16
|
},
|
|
11
17
|
stylesheet: {
|
|
12
|
-
regex: /<link[
|
|
18
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
13
19
|
endOffset: 1
|
|
14
20
|
},
|
|
15
21
|
modulepreload: {
|
|
16
|
-
regex: /<link[
|
|
22
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']modulepreload["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
17
23
|
endOffset: 1
|
|
18
24
|
}
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
// Extended caching mechanism with expiration time
|
|
28
|
+
class ResourceCache {
|
|
29
|
+
constructor(ttl = 3600000) { // Default cache for 1 hour
|
|
30
|
+
this.cache = new Map();
|
|
31
|
+
this.ttl = ttl;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(key) {
|
|
35
|
+
const item = this.cache.get(key);
|
|
36
|
+
if (!item) return undefined
|
|
37
|
+
|
|
38
|
+
// Check if expired
|
|
39
|
+
if (Date.now() > item.expiry) {
|
|
40
|
+
this.cache.delete(key);
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return item.value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
set(key, value) {
|
|
48
|
+
this.cache.set(key, {
|
|
49
|
+
value,
|
|
50
|
+
expiry: Date.now() + this.ttl
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
has(key) {
|
|
55
|
+
return this.get(key) !== undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clear() {
|
|
59
|
+
this.cache.clear();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const urlSupportCache = new ResourceCache();
|
|
64
|
+
const resourceCache = new ResourceCache();
|
|
22
65
|
|
|
66
|
+
// Check if URL is from a bypass domain
|
|
23
67
|
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
24
|
-
if (!url.startsWith('http')) return false
|
|
68
|
+
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
69
|
+
|
|
25
70
|
try {
|
|
26
71
|
const urlObj = new URL(url);
|
|
27
72
|
return bypassDomains.some(domain =>
|
|
28
73
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
29
74
|
)
|
|
30
|
-
} catch {
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Invalid URL: ${url}`, error);
|
|
31
77
|
return false
|
|
32
78
|
}
|
|
33
79
|
}
|
|
34
80
|
|
|
35
|
-
|
|
81
|
+
// Resource check with retry mechanism
|
|
82
|
+
async function checkResourceSupport(url, retries = 2) {
|
|
36
83
|
if (urlSupportCache.has(url)) {
|
|
37
84
|
return urlSupportCache.get(url)
|
|
38
85
|
}
|
|
39
86
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
87
|
+
let lastError;
|
|
88
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
92
|
+
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
method: 'HEAD',
|
|
95
|
+
signal: controller.signal
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
clearTimeout(timeoutId);
|
|
99
|
+
|
|
100
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
101
|
+
const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
|
|
102
|
+
urlSupportCache.set(url, isSupported);
|
|
103
|
+
return isSupported
|
|
104
|
+
} catch (error) {
|
|
105
|
+
lastError = error;
|
|
106
|
+
if (error.name === 'AbortError') {
|
|
107
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Resource check timed out: ${url}`);
|
|
108
|
+
break // Don't retry timeouts
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Don't wait after the last failed attempt
|
|
112
|
+
if (attempt < retries) {
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
52
116
|
}
|
|
117
|
+
|
|
118
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to check resource support: ${url}`, lastError);
|
|
119
|
+
urlSupportCache.set(url, false);
|
|
120
|
+
return false
|
|
53
121
|
}
|
|
54
122
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return
|
|
60
|
-
} catch (error) {
|
|
61
|
-
console.warn(`[vite-plugin-sri4] Failed to fetch external resource: ${url}`, error);
|
|
62
|
-
return null
|
|
123
|
+
// Optimized resource fetching function with retry mechanism and caching
|
|
124
|
+
async function fetchResource(url, retries = 1) {
|
|
125
|
+
// Check cache
|
|
126
|
+
if (resourceCache.has(url)) {
|
|
127
|
+
return resourceCache.get(url)
|
|
63
128
|
}
|
|
129
|
+
|
|
130
|
+
let lastError;
|
|
131
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
|
135
|
+
|
|
136
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
137
|
+
clearTimeout(timeoutId);
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`HTTP error! status: ${response.status}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
144
|
+
resourceCache.set(url, data);
|
|
145
|
+
return data
|
|
146
|
+
} catch (error) {
|
|
147
|
+
lastError = error;
|
|
148
|
+
if (error.name === 'AbortError') {
|
|
149
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Resource fetch timed out: ${url}`);
|
|
150
|
+
break // Don't retry timeouts
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (attempt < retries) {
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to fetch external resource: ${url}`, lastError);
|
|
160
|
+
return null
|
|
64
161
|
}
|
|
65
162
|
|
|
66
163
|
function createTransformer(options, config) {
|
|
67
|
-
const {
|
|
164
|
+
const {
|
|
165
|
+
ignoreMissingAsset,
|
|
166
|
+
bypassDomains,
|
|
167
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM
|
|
168
|
+
} = options;
|
|
68
169
|
|
|
170
|
+
// Improved method for getting bundle keys
|
|
69
171
|
const getBundleKey = (htmlPath, url) => {
|
|
172
|
+
// Handle absolute path URLs
|
|
173
|
+
if (url.startsWith('/')) {
|
|
174
|
+
// Remove leading slash to match keys in bundle
|
|
175
|
+
return url.substring(1)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Handle relative paths (when config.base is relative)
|
|
70
179
|
if (config.base === './' || config.base === '') {
|
|
71
|
-
return path.posix.resolve(htmlPath, url)
|
|
180
|
+
return path.posix.resolve(path.posix.dirname(htmlPath), url)
|
|
72
181
|
}
|
|
73
|
-
|
|
182
|
+
|
|
183
|
+
// Handle other cases, remove base prefix from URL
|
|
184
|
+
return url.startsWith(config.base)
|
|
185
|
+
? url.substring(config.base.length)
|
|
186
|
+
: url
|
|
74
187
|
};
|
|
75
188
|
|
|
76
189
|
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
190
|
+
// Skip specified domains
|
|
77
191
|
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
78
192
|
return null
|
|
79
193
|
}
|
|
@@ -85,41 +199,93 @@ function createTransformer(options, config) {
|
|
|
85
199
|
source = await fetchResource(url);
|
|
86
200
|
if (!source) return null
|
|
87
201
|
} else {
|
|
88
|
-
const
|
|
202
|
+
const bundleKey = getBundleKey(htmlPath, url);
|
|
203
|
+
const bundleItem = bundle[bundleKey];
|
|
204
|
+
|
|
89
205
|
if (!bundleItem) {
|
|
90
|
-
|
|
91
|
-
|
|
206
|
+
// Try to find a matching item with more flexible matching
|
|
207
|
+
const possibleMatch = Object.keys(bundle).find(key =>
|
|
208
|
+
key.endsWith(bundleKey) || bundleKey.endsWith(key)
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (possibleMatch) {
|
|
212
|
+
source = bundle[possibleMatch].type === 'chunk'
|
|
213
|
+
? bundle[possibleMatch].code
|
|
214
|
+
: bundle[possibleMatch].source;
|
|
215
|
+
} else if (ignoreMissingAsset) {
|
|
216
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Asset not found in bundle: ${url} (path: ${htmlPath}, key: ${bundleKey})`);
|
|
217
|
+
return null
|
|
218
|
+
} else {
|
|
219
|
+
throw new Error(`Asset ${url} not found in bundle (path: ${htmlPath}, key: ${bundleKey})`)
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
92
223
|
}
|
|
93
|
-
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
94
224
|
}
|
|
95
225
|
|
|
96
|
-
|
|
226
|
+
// Ensure source is a Uint8Array or string
|
|
227
|
+
if (!source) return null
|
|
228
|
+
|
|
229
|
+
if (typeof source === 'string') {
|
|
230
|
+
return `${hashAlgorithm}-${createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return `${hashAlgorithm}-${createHash(hashAlgorithm).update(Buffer.from(source)).digest('base64')}`
|
|
97
234
|
};
|
|
98
235
|
|
|
99
236
|
const transformHTML = async (bundle, htmlPath, html) => {
|
|
237
|
+
if (!html || typeof html !== 'string') {
|
|
238
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Invalid HTML content for ${htmlPath}`);
|
|
239
|
+
return html
|
|
240
|
+
}
|
|
241
|
+
|
|
100
242
|
const changes = [];
|
|
101
243
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
244
|
+
// Collect changes from all patterns in parallel
|
|
245
|
+
await Promise.all(
|
|
246
|
+
Object.values(HTML_PATTERNS).map(async ({ regex, endOffset }) => {
|
|
247
|
+
const matches = [...html.matchAll(regex)];
|
|
248
|
+
|
|
249
|
+
// Process each match in parallel
|
|
250
|
+
const matchResults = await Promise.all(
|
|
251
|
+
matches.map(async match => {
|
|
252
|
+
const [, url] = match;
|
|
253
|
+
if (!url) return null
|
|
254
|
+
|
|
255
|
+
const end = match.index + match[0].length;
|
|
256
|
+
const integrity = await calculateIntegrity(bundle, htmlPath, url);
|
|
257
|
+
|
|
258
|
+
if (integrity) {
|
|
259
|
+
return {
|
|
260
|
+
integrity,
|
|
261
|
+
position: end - endOffset,
|
|
262
|
+
url // For logging
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Filter out null results
|
|
270
|
+
matchResults.filter(Boolean).forEach(result => changes.push(result));
|
|
271
|
+
})
|
|
272
|
+
);
|
|
117
273
|
|
|
274
|
+
// Sort by position in descending order to insert from back to front (won't affect insertion points ahead)
|
|
118
275
|
changes.sort((a, b) => b.position - a.position);
|
|
119
276
|
|
|
120
|
-
|
|
277
|
+
// Check if identical integrity attributes already exist to avoid duplicates
|
|
278
|
+
for (const { integrity, position, url } of changes) {
|
|
121
279
|
const insertText = ` integrity="${integrity}"`;
|
|
280
|
+
|
|
281
|
+
// Check if integrity attribute already exists
|
|
282
|
+
const segment = html.slice(Math.max(0, position - 100), position + 100);
|
|
283
|
+
if (segment.includes(`integrity="${integrity}"`)) {
|
|
284
|
+
continue // Skip elements that already have the same integrity
|
|
285
|
+
}
|
|
286
|
+
|
|
122
287
|
html = html.slice(0, position) + insertText + html.slice(position);
|
|
288
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] Added integrity for: ${url}`);
|
|
123
289
|
}
|
|
124
290
|
|
|
125
291
|
return html
|
|
@@ -132,13 +298,37 @@ function sri(options = {}) {
|
|
|
132
298
|
const {
|
|
133
299
|
ignoreMissingAsset = false,
|
|
134
300
|
bypassDomains = [],
|
|
135
|
-
hashAlgorithm =
|
|
301
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
302
|
+
logLevel = 'warn'
|
|
136
303
|
} = options;
|
|
137
304
|
|
|
305
|
+
// Adjust log level
|
|
306
|
+
const originalConsoleWarn = console.warn;
|
|
307
|
+
const originalConsoleDebug = console.debug;
|
|
308
|
+
|
|
309
|
+
if (logLevel === 'error') {
|
|
310
|
+
console.warn = () => {};
|
|
311
|
+
console.debug = () => {};
|
|
312
|
+
} else if (logLevel === 'warn') {
|
|
313
|
+
console.debug = () => {};
|
|
314
|
+
}
|
|
315
|
+
|
|
138
316
|
return {
|
|
139
|
-
name:
|
|
317
|
+
name: DEFAULT_PLUGIN_NAME,
|
|
140
318
|
enforce: 'post',
|
|
141
319
|
apply: 'build',
|
|
320
|
+
|
|
321
|
+
// Cleanup work
|
|
322
|
+
buildEnd() {
|
|
323
|
+
// Restore console functions
|
|
324
|
+
console.warn = originalConsoleWarn;
|
|
325
|
+
console.debug = originalConsoleDebug;
|
|
326
|
+
|
|
327
|
+
// Clear caches
|
|
328
|
+
urlSupportCache.clear();
|
|
329
|
+
resourceCache.clear();
|
|
330
|
+
},
|
|
331
|
+
|
|
142
332
|
configResolved(config) {
|
|
143
333
|
const transformer = createTransformer({
|
|
144
334
|
ignoreMissingAsset,
|
|
@@ -153,16 +343,32 @@ function sri(options = {}) {
|
|
|
153
343
|
/\.html?$/.test(chunk.fileName)
|
|
154
344
|
);
|
|
155
345
|
|
|
346
|
+
if (htmlFiles.length === 0) {
|
|
347
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] No HTML files found in bundle`);
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Process all HTML files in parallel
|
|
156
352
|
await Promise.all(
|
|
157
353
|
htmlFiles.map(async ([name, chunk]) => {
|
|
158
|
-
|
|
354
|
+
try {
|
|
355
|
+
const originalContent = chunk.source.toString();
|
|
356
|
+
chunk.source = await transformer.transformHTML(bundle, name, originalContent);
|
|
357
|
+
|
|
358
|
+
if (originalContent !== chunk.source) {
|
|
359
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] SRI attributes added to ${name}`);
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Error processing ${name}:`, error);
|
|
363
|
+
// Keep original content on error
|
|
364
|
+
}
|
|
159
365
|
})
|
|
160
366
|
);
|
|
161
367
|
};
|
|
162
368
|
|
|
163
369
|
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
164
370
|
if (!plugin) {
|
|
165
|
-
throw new Error(
|
|
371
|
+
throw new Error(`[${DEFAULT_PLUGIN_NAME}] requires Vite 2.0.0 or higher`)
|
|
166
372
|
}
|
|
167
373
|
|
|
168
374
|
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
@@ -182,4 +388,4 @@ function sri(options = {}) {
|
|
|
182
388
|
}
|
|
183
389
|
}
|
|
184
390
|
|
|
185
|
-
export { sri };
|
|
391
|
+
export { sri as default, sri };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-sri4",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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",
|
|
@@ -27,17 +27,17 @@
|
|
|
27
27
|
"cross-fetch": "^4.1.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"vite": "^
|
|
30
|
+
"vite": "^6.0.0 || ^7.0.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@rollup/plugin-commonjs": "^25.0.7",
|
|
34
34
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
35
|
-
"@vitest/coverage-v8": "^
|
|
35
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
36
36
|
"cross-fetch": "^4.0.0",
|
|
37
|
+
"memfs": "^4.6.0",
|
|
37
38
|
"rollup": "^4.9.6",
|
|
38
|
-
"vite": "^
|
|
39
|
-
"vitest": "^
|
|
40
|
-
"memfs": "^4.6.0"
|
|
39
|
+
"vite": "^7.0.0",
|
|
40
|
+
"vitest": "^3.2.4"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"vite",
|