vite-plugin-sri4 1.8.7 → 2.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/dist/index.cjs +335 -215
- package/dist/index.js +332 -215
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -1,276 +1,396 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
var fetch = require('cross-fetch');
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
var crypto = require('crypto');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
var fetch = require('cross-fetch');
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// Constants definition
|
|
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
|
|
16
|
+
const HTML_PATTERNS = {
|
|
17
|
+
script: {
|
|
18
|
+
regex: /<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>/g,
|
|
19
|
+
endOffset: 10
|
|
20
|
+
},
|
|
21
|
+
stylesheet: {
|
|
22
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
23
|
+
endOffset: 1
|
|
24
|
+
},
|
|
25
|
+
modulepreload: {
|
|
26
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']modulepreload["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
27
|
+
endOffset: 1
|
|
28
|
+
}
|
|
14
29
|
};
|
|
15
30
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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;
|
|
19
36
|
}
|
|
20
|
-
}
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error('Invalid content type');
|
|
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
|
|
31
46
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
console.error(`${LOG_PREFIX} Failed to compute SRI hash: ${error}`);
|
|
35
|
-
return null;
|
|
47
|
+
|
|
48
|
+
return item.value
|
|
36
49
|
}
|
|
37
|
-
}
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
set(key, value) {
|
|
52
|
+
this.cache.set(key, {
|
|
53
|
+
value,
|
|
54
|
+
expiry: Date.now() + this.ttl
|
|
43
55
|
});
|
|
44
|
-
const acao = response.headers.get('access-control-allow-origin');
|
|
45
|
-
if (acao && (acao === '*' || acao.includes(options.domain || ''))) {
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
return false;
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.error(`${LOG_PREFIX} Failed to fetch CORS headers from ${url}`, error);
|
|
51
|
-
return false;
|
|
52
56
|
}
|
|
53
|
-
}
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
hostname = hostname.slice(8);
|
|
64
|
-
} else if (hostname.startsWith('//')) {
|
|
65
|
-
hostname = hostname.slice(2);
|
|
66
|
-
}
|
|
58
|
+
has(key) {
|
|
59
|
+
return this.get(key) !== undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear() {
|
|
63
|
+
this.cache.clear();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
67
66
|
|
|
68
|
-
|
|
67
|
+
const urlSupportCache = new ResourceCache();
|
|
68
|
+
const resourceCache = new ResourceCache();
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// Check if URL is from a bypass domain
|
|
71
|
+
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
72
|
+
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
71
73
|
|
|
74
|
+
try {
|
|
75
|
+
const urlObj = new URL(url);
|
|
72
76
|
return bypassDomains.some(domain =>
|
|
73
|
-
hostname === domain || hostname.endsWith(`.${domain}`)
|
|
74
|
-
)
|
|
75
|
-
} catch (
|
|
76
|
-
|
|
77
|
+
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
78
|
+
)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Invalid URL: ${url}`, error);
|
|
81
|
+
return false
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
// Resource check with retry mechanism
|
|
86
|
+
async function checkResourceSupport(url, retries = 2) {
|
|
87
|
+
if (urlSupportCache.has(url)) {
|
|
88
|
+
return urlSupportCache.get(url)
|
|
89
|
+
}
|
|
90
|
+
|
|
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);
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|
|
105
120
|
}
|
|
106
121
|
|
|
107
|
-
|
|
122
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to check resource support: ${url}`, lastError);
|
|
123
|
+
urlSupportCache.set(url, false);
|
|
124
|
+
return false
|
|
108
125
|
}
|
|
109
126
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
|
116
133
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
|
121
139
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
132
155
|
}
|
|
133
156
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// Find script tags
|
|
138
|
-
const scriptTagRegex = /<script[^>]+src=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
|
|
139
|
-
let match;
|
|
140
|
-
while ((match = scriptTagRegex.exec(html)) !== null) {
|
|
141
|
-
const [tag, quotedUrl, unquotedUrl] = match;
|
|
142
|
-
resourceTags.push({
|
|
143
|
-
tag,
|
|
144
|
-
url: quotedUrl || unquotedUrl,
|
|
145
|
-
type: 'script'
|
|
146
|
-
});
|
|
157
|
+
if (attempt < retries) {
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
147
159
|
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to fetch external resource: ${url}`, lastError);
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createTransformer(options, config) {
|
|
168
|
+
const {
|
|
169
|
+
ignoreMissingAsset,
|
|
170
|
+
bypassDomains,
|
|
171
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM
|
|
172
|
+
} = options;
|
|
173
|
+
|
|
174
|
+
// Improved method for getting bundle keys
|
|
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)
|
|
183
|
+
if (config.base === './' || config.base === '') {
|
|
184
|
+
return path.posix.resolve(path.posix.dirname(htmlPath), url)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Handle other cases, remove base prefix from URL
|
|
188
|
+
return url.startsWith(config.base)
|
|
189
|
+
? url.substring(config.base.length)
|
|
190
|
+
: url
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
194
|
+
// Skip specified domains
|
|
195
|
+
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
148
198
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
199
|
+
let source;
|
|
200
|
+
if (url.startsWith('http')) {
|
|
201
|
+
const isSupported = await checkResourceSupport(url);
|
|
202
|
+
if (!isSupported) return null
|
|
203
|
+
source = await fetchResource(url);
|
|
204
|
+
if (!source) return null
|
|
205
|
+
} else {
|
|
206
|
+
const bundleKey = getBundleKey(htmlPath, url);
|
|
207
|
+
const bundleItem = bundle[bundleKey];
|
|
208
|
+
|
|
209
|
+
if (!bundleItem) {
|
|
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})`)
|
|
159
224
|
}
|
|
225
|
+
} else {
|
|
226
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
160
227
|
}
|
|
228
|
+
}
|
|
161
229
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
content: html,
|
|
165
|
-
resources: resourceTags
|
|
166
|
-
});
|
|
230
|
+
// Ensure source is a Uint8Array or string
|
|
231
|
+
if (!source) return null
|
|
167
232
|
|
|
168
|
-
|
|
169
|
-
|
|
233
|
+
if (typeof source === 'string') {
|
|
234
|
+
return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
235
|
+
}
|
|
170
236
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
let content = htmlInfo.content;
|
|
237
|
+
return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(Buffer.from(source)).digest('base64')}`
|
|
238
|
+
};
|
|
174
239
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
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
|
+
}
|
|
181
245
|
|
|
182
|
-
|
|
183
|
-
if (/^(https?:)?\/\//i.test(url)) {
|
|
184
|
-
if (isBypassDomain(url, options.bypassDomains)) {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
246
|
+
const changes = [];
|
|
187
247
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
newTag: sriCache.get(url)
|
|
193
|
-
};
|
|
194
|
-
}
|
|
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)];
|
|
195
252
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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);
|
|
200
261
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const hasCrossOrigin = hasCrossOriginAttr(tag);
|
|
207
|
-
const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
|
|
208
|
-
const newTag = tag.replace(/>$/, ` integrity="${hash}"${crossOriginAttr}>`);
|
|
209
|
-
sriCache.set(url, newTag);
|
|
210
|
-
return { tag, newTag };
|
|
211
|
-
}
|
|
212
|
-
} catch (error) {
|
|
213
|
-
log(`Failed to process external resource ${url}: ${error}`, options);
|
|
262
|
+
if (integrity) {
|
|
263
|
+
return {
|
|
264
|
+
integrity,
|
|
265
|
+
position: end - endOffset,
|
|
266
|
+
url // For logging
|
|
214
267
|
}
|
|
215
|
-
return null;
|
|
216
268
|
}
|
|
269
|
+
return null
|
|
270
|
+
})
|
|
271
|
+
);
|
|
217
272
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
273
|
+
// Filter out null results
|
|
274
|
+
matchResults.filter(Boolean).forEach(result => changes.push(result));
|
|
275
|
+
})
|
|
276
|
+
);
|
|
221
277
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
bundleItem = bundle[key];
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
278
|
+
// Sort by position in descending order to insert from back to front (won't affect insertion points ahead)
|
|
279
|
+
changes.sort((a, b) => b.position - a.position);
|
|
228
280
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
281
|
+
// Check if identical integrity attributes already exist to avoid duplicates
|
|
282
|
+
for (const { integrity, position, url } of changes) {
|
|
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
|
+
|
|
291
|
+
html = html.slice(0, position) + insertText + html.slice(position);
|
|
292
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] Added integrity for: ${url}`);
|
|
293
|
+
}
|
|
235
294
|
|
|
295
|
+
return html
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return { transformHTML, calculateIntegrity }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function sri(options = {}) {
|
|
302
|
+
const {
|
|
303
|
+
ignoreMissingAsset = false,
|
|
304
|
+
bypassDomains = [],
|
|
305
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
306
|
+
logLevel = 'warn'
|
|
307
|
+
} = options;
|
|
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
|
+
|
|
320
|
+
return {
|
|
321
|
+
name: DEFAULT_PLUGIN_NAME,
|
|
322
|
+
enforce: 'post',
|
|
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
|
+
|
|
336
|
+
configResolved(config) {
|
|
337
|
+
const transformer = createTransformer({
|
|
338
|
+
ignoreMissingAsset,
|
|
339
|
+
bypassDomains,
|
|
340
|
+
hashAlgorithm
|
|
341
|
+
}, config);
|
|
342
|
+
|
|
343
|
+
const generateBundle = async function(_, bundle) {
|
|
344
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
345
|
+
([, chunk]) =>
|
|
346
|
+
chunk.type === 'asset' &&
|
|
347
|
+
/\.html?$/.test(chunk.fileName)
|
|
348
|
+
);
|
|
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
|
|
356
|
+
await Promise.all(
|
|
357
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
236
358
|
try {
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
|
|
243
|
-
const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
|
|
244
|
-
return { tag, newTag };
|
|
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}`);
|
|
245
364
|
}
|
|
246
365
|
} catch (error) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
366
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Error processing ${name}:`, error);
|
|
367
|
+
// Keep original content on error
|
|
250
368
|
}
|
|
251
|
-
|
|
252
|
-
return null;
|
|
253
369
|
})
|
|
254
370
|
);
|
|
371
|
+
};
|
|
255
372
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
content = content.replace(update.tag, update.newTag);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// Write the modified content back to the bundle
|
|
264
|
-
if (bundle[filename]) {
|
|
265
|
-
bundle[filename].source = content;
|
|
266
|
-
}
|
|
373
|
+
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
374
|
+
if (!plugin) {
|
|
375
|
+
throw new Error(`[${DEFAULT_PLUGIN_NAME}] requires Vite 2.0.0 or higher`)
|
|
267
376
|
}
|
|
268
377
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
378
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
379
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
380
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
381
|
+
await originalHandler.apply(this, args);
|
|
382
|
+
await generateBundle.apply(this, args);
|
|
383
|
+
};
|
|
384
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
385
|
+
const originalHandler = plugin.generateBundle;
|
|
386
|
+
plugin.generateBundle = async function(...args) {
|
|
387
|
+
await originalHandler.apply(this, args);
|
|
388
|
+
await generateBundle.apply(this, args);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
272
391
|
}
|
|
273
|
-
}
|
|
392
|
+
}
|
|
274
393
|
}
|
|
275
394
|
|
|
276
|
-
|
|
395
|
+
exports.default = sri;
|
|
396
|
+
exports.sri = sri;
|
package/dist/index.js
CHANGED
|
@@ -1,274 +1,391 @@
|
|
|
1
|
-
import { createHash } from '
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import fetch from 'cross-fetch';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
// Constants definition
|
|
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
|
|
12
|
+
const HTML_PATTERNS = {
|
|
13
|
+
script: {
|
|
14
|
+
regex: /<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>/g,
|
|
15
|
+
endOffset: 10
|
|
16
|
+
},
|
|
17
|
+
stylesheet: {
|
|
18
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']stylesheet["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
19
|
+
endOffset: 1
|
|
20
|
+
},
|
|
21
|
+
modulepreload: {
|
|
22
|
+
regex: /<link\b[^>]*?\brel\s*=\s*["']modulepreload["'][^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/g,
|
|
23
|
+
endOffset: 1
|
|
24
|
+
}
|
|
12
25
|
};
|
|
13
26
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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;
|
|
17
32
|
}
|
|
18
|
-
}
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error('Invalid content type');
|
|
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
|
|
29
42
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
console.error(`${LOG_PREFIX} Failed to compute SRI hash: ${error}`);
|
|
33
|
-
return null;
|
|
43
|
+
|
|
44
|
+
return item.value
|
|
34
45
|
}
|
|
35
|
-
}
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
set(key, value) {
|
|
48
|
+
this.cache.set(key, {
|
|
49
|
+
value,
|
|
50
|
+
expiry: Date.now() + this.ttl
|
|
41
51
|
});
|
|
42
|
-
const acao = response.headers.get('access-control-allow-origin');
|
|
43
|
-
if (acao && (acao === '*' || acao.includes(options.domain || ''))) {
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
return false;
|
|
47
|
-
} catch (error) {
|
|
48
|
-
console.error(`${LOG_PREFIX} Failed to fetch CORS headers from ${url}`, error);
|
|
49
|
-
return false;
|
|
50
52
|
}
|
|
51
|
-
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let hostname = url;
|
|
57
|
-
|
|
58
|
-
if (hostname.startsWith('http://')) {
|
|
59
|
-
hostname = hostname.slice(7);
|
|
60
|
-
} else if (hostname.startsWith('https://')) {
|
|
61
|
-
hostname = hostname.slice(8);
|
|
62
|
-
} else if (hostname.startsWith('//')) {
|
|
63
|
-
hostname = hostname.slice(2);
|
|
64
|
-
}
|
|
54
|
+
has(key) {
|
|
55
|
+
return this.get(key) !== undefined
|
|
56
|
+
}
|
|
65
57
|
|
|
66
|
-
|
|
58
|
+
clear() {
|
|
59
|
+
this.cache.clear();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const urlSupportCache = new ResourceCache();
|
|
64
|
+
const resourceCache = new ResourceCache();
|
|
67
65
|
|
|
68
|
-
|
|
66
|
+
// Check if URL is from a bypass domain
|
|
67
|
+
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
68
|
+
if (!url || typeof url !== 'string' || !url.startsWith('http')) return false
|
|
69
69
|
|
|
70
|
+
try {
|
|
71
|
+
const urlObj = new URL(url);
|
|
70
72
|
return bypassDomains.some(domain =>
|
|
71
|
-
hostname === domain || hostname.endsWith(`.${domain}`)
|
|
72
|
-
)
|
|
73
|
-
} catch (
|
|
74
|
-
|
|
73
|
+
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
74
|
+
)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Invalid URL: ${url}`, error);
|
|
77
|
+
return false
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
// Resource check with retry mechanism
|
|
82
|
+
async function checkResourceSupport(url, retries = 2) {
|
|
83
|
+
if (urlSupportCache.has(url)) {
|
|
84
|
+
return urlSupportCache.get(url)
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
+
}
|
|
81
110
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// Try different path combinations
|
|
89
|
-
const paths = [
|
|
90
|
-
cleanUrl,
|
|
91
|
-
`static/${cleanUrl}`,
|
|
92
|
-
cleanUrl.replace(/^static\//, '')
|
|
93
|
-
];
|
|
94
|
-
|
|
95
|
-
// Remove hash part if exists and try again
|
|
96
|
-
const withoutHash = cleanUrl.replace(/-[a-zA-Z0-9]+\.([^.]+)$/, '.$1');
|
|
97
|
-
if (withoutHash !== cleanUrl) {
|
|
98
|
-
paths.push(...[
|
|
99
|
-
withoutHash,
|
|
100
|
-
`static/${withoutHash}`,
|
|
101
|
-
withoutHash.replace(/^static\//, '')
|
|
102
|
-
]);
|
|
111
|
+
// Don't wait after the last failed attempt
|
|
112
|
+
if (attempt < retries) {
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
103
116
|
}
|
|
104
117
|
|
|
105
|
-
|
|
118
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to check resource support: ${url}`, lastError);
|
|
119
|
+
urlSupportCache.set(url, false);
|
|
120
|
+
return false
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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)
|
|
128
|
+
}
|
|
114
129
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
119
135
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
142
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
130
151
|
}
|
|
131
152
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// Find script tags
|
|
136
|
-
const scriptTagRegex = /<script[^>]+src=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
|
|
137
|
-
let match;
|
|
138
|
-
while ((match = scriptTagRegex.exec(html)) !== null) {
|
|
139
|
-
const [tag, quotedUrl, unquotedUrl] = match;
|
|
140
|
-
resourceTags.push({
|
|
141
|
-
tag,
|
|
142
|
-
url: quotedUrl || unquotedUrl,
|
|
143
|
-
type: 'script'
|
|
144
|
-
});
|
|
153
|
+
if (attempt < retries) {
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
145
155
|
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Failed to fetch external resource: ${url}`, lastError);
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
146
162
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
function createTransformer(options, config) {
|
|
164
|
+
const {
|
|
165
|
+
ignoreMissingAsset,
|
|
166
|
+
bypassDomains,
|
|
167
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
// Improved method for getting bundle keys
|
|
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)
|
|
179
|
+
if (config.base === './' || config.base === '') {
|
|
180
|
+
return path.posix.resolve(path.posix.dirname(htmlPath), url)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Handle other cases, remove base prefix from URL
|
|
184
|
+
return url.startsWith(config.base)
|
|
185
|
+
? url.substring(config.base.length)
|
|
186
|
+
: url
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
190
|
+
// Skip specified domains
|
|
191
|
+
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let source;
|
|
196
|
+
if (url.startsWith('http')) {
|
|
197
|
+
const isSupported = await checkResourceSupport(url);
|
|
198
|
+
if (!isSupported) return null
|
|
199
|
+
source = await fetchResource(url);
|
|
200
|
+
if (!source) return null
|
|
201
|
+
} else {
|
|
202
|
+
const bundleKey = getBundleKey(htmlPath, url);
|
|
203
|
+
const bundleItem = bundle[bundleKey];
|
|
204
|
+
|
|
205
|
+
if (!bundleItem) {
|
|
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})`)
|
|
157
220
|
}
|
|
221
|
+
} else {
|
|
222
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
158
223
|
}
|
|
224
|
+
}
|
|
159
225
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
content: html,
|
|
163
|
-
resources: resourceTags
|
|
164
|
-
});
|
|
226
|
+
// Ensure source is a Uint8Array or string
|
|
227
|
+
if (!source) return null
|
|
165
228
|
|
|
166
|
-
|
|
167
|
-
|
|
229
|
+
if (typeof source === 'string') {
|
|
230
|
+
return `${hashAlgorithm}-${createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
231
|
+
}
|
|
168
232
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
let content = htmlInfo.content;
|
|
233
|
+
return `${hashAlgorithm}-${createHash(hashAlgorithm).update(Buffer.from(source)).digest('base64')}`
|
|
234
|
+
};
|
|
172
235
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
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
|
+
}
|
|
179
241
|
|
|
180
|
-
|
|
181
|
-
if (/^(https?:)?\/\//i.test(url)) {
|
|
182
|
-
if (isBypassDomain(url, options.bypassDomains)) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
242
|
+
const changes = [];
|
|
185
243
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
newTag: sriCache.get(url)
|
|
191
|
-
};
|
|
192
|
-
}
|
|
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)];
|
|
193
248
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
198
254
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
sriCache.set(url, newTag);
|
|
208
|
-
return { tag, newTag };
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
log(`Failed to process external resource ${url}: ${error}`, options);
|
|
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
|
|
212
263
|
}
|
|
213
|
-
return null;
|
|
214
264
|
}
|
|
265
|
+
return null
|
|
266
|
+
})
|
|
267
|
+
);
|
|
215
268
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
269
|
+
// Filter out null results
|
|
270
|
+
matchResults.filter(Boolean).forEach(result => changes.push(result));
|
|
271
|
+
})
|
|
272
|
+
);
|
|
219
273
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
bundleItem = bundle[key];
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
274
|
+
// Sort by position in descending order to insert from back to front (won't affect insertion points ahead)
|
|
275
|
+
changes.sort((a, b) => b.position - a.position);
|
|
226
276
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
277
|
+
// Check if identical integrity attributes already exist to avoid duplicates
|
|
278
|
+
for (const { integrity, position, url } of changes) {
|
|
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
|
+
}
|
|
233
286
|
|
|
287
|
+
html = html.slice(0, position) + insertText + html.slice(position);
|
|
288
|
+
console.debug(`[${DEFAULT_PLUGIN_NAME}] Added integrity for: ${url}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return html
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return { transformHTML, calculateIntegrity }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function sri(options = {}) {
|
|
298
|
+
const {
|
|
299
|
+
ignoreMissingAsset = false,
|
|
300
|
+
bypassDomains = [],
|
|
301
|
+
hashAlgorithm = DEFAULT_HASH_ALGORITHM,
|
|
302
|
+
logLevel = 'warn'
|
|
303
|
+
} = options;
|
|
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
|
+
|
|
316
|
+
return {
|
|
317
|
+
name: DEFAULT_PLUGIN_NAME,
|
|
318
|
+
enforce: 'post',
|
|
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
|
+
|
|
332
|
+
configResolved(config) {
|
|
333
|
+
const transformer = createTransformer({
|
|
334
|
+
ignoreMissingAsset,
|
|
335
|
+
bypassDomains,
|
|
336
|
+
hashAlgorithm
|
|
337
|
+
}, config);
|
|
338
|
+
|
|
339
|
+
const generateBundle = async function(_, bundle) {
|
|
340
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
341
|
+
([, chunk]) =>
|
|
342
|
+
chunk.type === 'asset' &&
|
|
343
|
+
/\.html?$/.test(chunk.fileName)
|
|
344
|
+
);
|
|
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
|
|
352
|
+
await Promise.all(
|
|
353
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
234
354
|
try {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
|
|
241
|
-
const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
|
|
242
|
-
return { tag, newTag };
|
|
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}`);
|
|
243
360
|
}
|
|
244
361
|
} catch (error) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
362
|
+
console.warn(`[${DEFAULT_PLUGIN_NAME}] Error processing ${name}:`, error);
|
|
363
|
+
// Keep original content on error
|
|
248
364
|
}
|
|
249
|
-
|
|
250
|
-
return null;
|
|
251
365
|
})
|
|
252
366
|
);
|
|
367
|
+
};
|
|
253
368
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
content = content.replace(update.tag, update.newTag);
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// Write the modified content back to the bundle
|
|
262
|
-
if (bundle[filename]) {
|
|
263
|
-
bundle[filename].source = content;
|
|
264
|
-
}
|
|
369
|
+
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
370
|
+
if (!plugin) {
|
|
371
|
+
throw new Error(`[${DEFAULT_PLUGIN_NAME}] requires Vite 2.0.0 or higher`)
|
|
265
372
|
}
|
|
266
373
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
374
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
375
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
376
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
377
|
+
await originalHandler.apply(this, args);
|
|
378
|
+
await generateBundle.apply(this, args);
|
|
379
|
+
};
|
|
380
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
381
|
+
const originalHandler = plugin.generateBundle;
|
|
382
|
+
plugin.generateBundle = async function(...args) {
|
|
383
|
+
await originalHandler.apply(this, args);
|
|
384
|
+
await generateBundle.apply(this, args);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
270
387
|
}
|
|
271
|
-
}
|
|
388
|
+
}
|
|
272
389
|
}
|
|
273
390
|
|
|
274
|
-
export { sri as default };
|
|
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": "2.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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"prepublishOnly": "npm run build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
+
"cheerio": "^1.0.0",
|
|
26
27
|
"cross-fetch": "^4.1.0"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
@@ -32,9 +33,11 @@
|
|
|
32
33
|
"@rollup/plugin-commonjs": "^25.0.7",
|
|
33
34
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
34
35
|
"@vitest/coverage-v8": "^1.2.2",
|
|
36
|
+
"cross-fetch": "^4.0.0",
|
|
35
37
|
"rollup": "^4.9.6",
|
|
36
38
|
"vite": "^5.0.12",
|
|
37
|
-
"vitest": "^1.2.2"
|
|
39
|
+
"vitest": "^1.2.2",
|
|
40
|
+
"memfs": "^4.6.0"
|
|
38
41
|
},
|
|
39
42
|
"keywords": [
|
|
40
43
|
"vite",
|