vite-plugin-sri4 1.8.7 → 1.9.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 +141 -230
- package/dist/index.js +141 -230
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -1,276 +1,187 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
var path = require('path');
|
|
4
5
|
var fetch = require('cross-fetch');
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
|
|
8
|
+
const HTML_PATTERNS = {
|
|
9
|
+
script: {
|
|
10
|
+
regex: /<script[^<>]*['"]*src['"]*=['"]*([^ '"]+)['"]*[^<>]*><\/script>/g,
|
|
11
|
+
endOffset: 10
|
|
12
|
+
},
|
|
13
|
+
stylesheet: {
|
|
14
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*stylesheet['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
15
|
+
endOffset: 1
|
|
16
|
+
},
|
|
17
|
+
modulepreload: {
|
|
18
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*modulepreload['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
19
|
+
endOffset: 1
|
|
20
|
+
}
|
|
14
21
|
};
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
if (options.debug) {
|
|
18
|
-
console.log(`${LOG_PREFIX} ${message}`);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
23
|
+
const urlSupportCache = new Map();
|
|
21
24
|
|
|
22
|
-
function
|
|
25
|
+
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
26
|
+
if (!url.startsWith('http')) return false
|
|
23
27
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error('Invalid content type');
|
|
31
|
-
}
|
|
32
|
-
return `${algorithm}-${hash.digest('base64')}`;
|
|
33
|
-
} catch (error) {
|
|
34
|
-
console.error(`${LOG_PREFIX} Failed to compute SRI hash: ${error}`);
|
|
35
|
-
return null;
|
|
28
|
+
const urlObj = new URL(url);
|
|
29
|
+
return bypassDomains.some(domain =>
|
|
30
|
+
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
31
|
+
)
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
async function
|
|
37
|
+
async function checkResourceSupport(url) {
|
|
38
|
+
if (urlSupportCache.has(url)) {
|
|
39
|
+
return urlSupportCache.get(url)
|
|
40
|
+
}
|
|
41
|
+
|
|
40
42
|
try {
|
|
41
43
|
const response = await fetch(url, {
|
|
42
|
-
method: 'HEAD'
|
|
44
|
+
method: 'HEAD',
|
|
45
|
+
timeout: 5000
|
|
43
46
|
});
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return false;
|
|
47
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
48
|
+
const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
|
|
49
|
+
urlSupportCache.set(url, isSupported);
|
|
50
|
+
return isSupported
|
|
51
|
+
} catch {
|
|
52
|
+
urlSupportCache.set(url, false);
|
|
53
|
+
return false
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
function
|
|
56
|
-
if (!bypassDomains.length) return false;
|
|
57
|
+
async function fetchResource(url) {
|
|
57
58
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
} else if (hostname.startsWith('//')) {
|
|
65
|
-
hostname = hostname.slice(2);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
hostname = hostname.split('/')[0];
|
|
69
|
-
|
|
70
|
-
hostname = hostname.split(':')[0];
|
|
71
|
-
|
|
72
|
-
return bypassDomains.some(domain =>
|
|
73
|
-
hostname === domain || hostname.endsWith(`.${domain}`)
|
|
74
|
-
);
|
|
75
|
-
} catch (e) {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function hasCrossOriginAttr(tag) {
|
|
81
|
-
return /(?:^|\s)crossorigin(?:=["']?[^"'\s>]*["']?)?(?:\s|>|$)/i.test(tag);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function getBundleKey(url, base = '') {
|
|
85
|
-
// Remove base prefix if exists
|
|
86
|
-
let cleanUrl = url.startsWith(base) ? url.slice(base.length) : url;
|
|
87
|
-
// Remove leading slash
|
|
88
|
-
cleanUrl = cleanUrl.startsWith('/') ? cleanUrl.slice(1) : cleanUrl;
|
|
89
|
-
|
|
90
|
-
// Try different path combinations
|
|
91
|
-
const paths = [
|
|
92
|
-
cleanUrl,
|
|
93
|
-
`static/${cleanUrl}`,
|
|
94
|
-
cleanUrl.replace(/^static\//, '')
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
// Remove hash part if exists and try again
|
|
98
|
-
const withoutHash = cleanUrl.replace(/-[a-zA-Z0-9]+\.([^.]+)$/, '.$1');
|
|
99
|
-
if (withoutHash !== cleanUrl) {
|
|
100
|
-
paths.push(...[
|
|
101
|
-
withoutHash,
|
|
102
|
-
`static/${withoutHash}`,
|
|
103
|
-
withoutHash.replace(/^static\//, '')
|
|
104
|
-
]);
|
|
59
|
+
const response = await fetch(url, { timeout: 5000 });
|
|
60
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
61
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.warn(`[vite-plugin-sri4] Failed to fetch external resource: ${url}`, error);
|
|
64
|
+
return null
|
|
105
65
|
}
|
|
106
|
-
|
|
107
|
-
return [...new Set(paths)];
|
|
108
66
|
}
|
|
109
67
|
|
|
110
|
-
function
|
|
111
|
-
const
|
|
112
|
-
let isBuild = false;
|
|
113
|
-
let base = '';
|
|
114
|
-
const htmlFiles = new Map(); // Store HTML file info for processing
|
|
115
|
-
const sriCache = new Map(); // Cache SRI hashes
|
|
68
|
+
function createTransformer(options, config) {
|
|
69
|
+
const { ignoreMissingAsset, bypassDomains, hashAlgorithm = 'sha384' } = options;
|
|
116
70
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
71
|
+
const getBundleKey = (htmlPath, url) => {
|
|
72
|
+
if (config.base === './' || config.base === '') {
|
|
73
|
+
return path.posix.resolve(htmlPath, url)
|
|
74
|
+
}
|
|
75
|
+
return url.replace(config.base, '')
|
|
76
|
+
};
|
|
121
77
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
log('Plugin configured in ' + (isBuild ? 'build' : 'dev') + ' mode', options);
|
|
127
|
-
},
|
|
78
|
+
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
79
|
+
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
128
82
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
83
|
+
let source;
|
|
84
|
+
if (url.startsWith('http')) {
|
|
85
|
+
const isSupported = await checkResourceSupport(url);
|
|
86
|
+
if (!isSupported) return null
|
|
87
|
+
source = await fetchResource(url);
|
|
88
|
+
if (!source) return null
|
|
89
|
+
} else {
|
|
90
|
+
const bundleItem = bundle[getBundleKey(htmlPath, url)];
|
|
91
|
+
if (!bundleItem) {
|
|
92
|
+
if (ignoreMissingAsset) return null
|
|
93
|
+
throw new Error(`Asset ${url} not found in bundle`)
|
|
132
94
|
}
|
|
95
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
96
|
+
}
|
|
133
97
|
|
|
134
|
-
|
|
135
|
-
|
|
98
|
+
return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
99
|
+
};
|
|
136
100
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
});
|
|
147
|
-
}
|
|
101
|
+
const transformHTML = async (bundle, htmlPath, html) => {
|
|
102
|
+
const changes = [];
|
|
148
103
|
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
const [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
104
|
+
for (const { regex, endOffset } of Object.values(HTML_PATTERNS)) {
|
|
105
|
+
const matches = [...html.matchAll(regex)];
|
|
106
|
+
for (const match of matches) {
|
|
107
|
+
const [, url] = match;
|
|
108
|
+
const end = match.index + match[0].length;
|
|
109
|
+
|
|
110
|
+
const integrity = await calculateIntegrity(bundle, htmlPath, url);
|
|
111
|
+
if (integrity) {
|
|
112
|
+
changes.push({
|
|
113
|
+
integrity,
|
|
114
|
+
position: end - endOffset
|
|
158
115
|
});
|
|
159
116
|
}
|
|
160
117
|
}
|
|
118
|
+
}
|
|
161
119
|
|
|
162
|
-
|
|
163
|
-
htmlFiles.set(ctx.filename, {
|
|
164
|
-
content: html,
|
|
165
|
-
resources: resourceTags
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
return html;
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
async writeBundle(options, bundle) {
|
|
172
|
-
for (const [filename, htmlInfo] of htmlFiles) {
|
|
173
|
-
let content = htmlInfo.content;
|
|
174
|
-
|
|
175
|
-
// Process all resources in parallel
|
|
176
|
-
const updates = await Promise.all(
|
|
177
|
-
htmlInfo.resources.map(async ({ tag, url, type }) => {
|
|
178
|
-
if (tag.includes('integrity=')) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Handle external resources
|
|
183
|
-
if (/^(https?:)?\/\//i.test(url)) {
|
|
184
|
-
if (isBypassDomain(url, options.bypassDomains)) {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Check cache first
|
|
189
|
-
if (sriCache.has(url)) {
|
|
190
|
-
return {
|
|
191
|
-
tag,
|
|
192
|
-
newTag: sriCache.get(url)
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const corsOk = await externalResourceIsCorsEnabled(url, options);
|
|
197
|
-
if (!corsOk) {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const response = await fetch(url);
|
|
203
|
-
const content = await response.arrayBuffer();
|
|
204
|
-
const hash = computeSri(Buffer.from(content), options.algorithm);
|
|
205
|
-
if (hash) {
|
|
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);
|
|
214
|
-
}
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
120
|
+
changes.sort((a, b) => b.position - a.position);
|
|
217
121
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
122
|
+
for (const { integrity, position } of changes) {
|
|
123
|
+
const insertText = ` integrity="${integrity}"`;
|
|
124
|
+
html = html.slice(0, position) + insertText + html.slice(position);
|
|
125
|
+
}
|
|
221
126
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
bundleItem = bundle[key];
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
127
|
+
return html
|
|
128
|
+
};
|
|
228
129
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
console.warn(`${LOG_PREFIX} Asset not found in bundle: ${url}`);
|
|
232
|
-
}
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
130
|
+
return { transformHTML, calculateIntegrity }
|
|
131
|
+
}
|
|
235
132
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
133
|
+
function sri(options = {}) {
|
|
134
|
+
const {
|
|
135
|
+
ignoreMissingAsset = false,
|
|
136
|
+
bypassDomains = [],
|
|
137
|
+
hashAlgorithm = 'sha384'
|
|
138
|
+
} = options;
|
|
239
139
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
140
|
+
return {
|
|
141
|
+
name: 'vite-plugin-sri4',
|
|
142
|
+
enforce: 'post',
|
|
143
|
+
apply: 'build',
|
|
144
|
+
configResolved(config) {
|
|
145
|
+
const transformer = createTransformer({
|
|
146
|
+
ignoreMissingAsset,
|
|
147
|
+
bypassDomains,
|
|
148
|
+
hashAlgorithm
|
|
149
|
+
}, config);
|
|
150
|
+
|
|
151
|
+
const generateBundle = async function(_, bundle) {
|
|
152
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
153
|
+
([, chunk]) =>
|
|
154
|
+
chunk.type === 'asset' &&
|
|
155
|
+
/\.html?$/.test(chunk.fileName)
|
|
156
|
+
);
|
|
251
157
|
|
|
252
|
-
|
|
158
|
+
await Promise.all(
|
|
159
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
160
|
+
chunk.source = await transformer.transformHTML(bundle, name, chunk.source.toString());
|
|
253
161
|
})
|
|
254
162
|
);
|
|
163
|
+
};
|
|
255
164
|
|
|
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
|
-
}
|
|
165
|
+
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
166
|
+
if (!plugin) {
|
|
167
|
+
throw new Error('vite-plugin-sri4 requires Vite 2.0.0 or higher')
|
|
267
168
|
}
|
|
268
169
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
170
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
171
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
172
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
173
|
+
await originalHandler.apply(this, args);
|
|
174
|
+
await generateBundle.apply(this, args);
|
|
175
|
+
};
|
|
176
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
177
|
+
const originalHandler = plugin.generateBundle;
|
|
178
|
+
plugin.generateBundle = async function(...args) {
|
|
179
|
+
await originalHandler.apply(this, args);
|
|
180
|
+
await generateBundle.apply(this, args);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
272
183
|
}
|
|
273
|
-
}
|
|
184
|
+
}
|
|
274
185
|
}
|
|
275
186
|
|
|
276
|
-
|
|
187
|
+
exports.sri = sri;
|
package/dist/index.js
CHANGED
|
@@ -1,274 +1,185 @@
|
|
|
1
|
-
import { createHash } from '
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import fetch from 'cross-fetch';
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis';
|
|
6
|
+
const HTML_PATTERNS = {
|
|
7
|
+
script: {
|
|
8
|
+
regex: /<script[^<>]*['"]*src['"]*=['"]*([^ '"]+)['"]*[^<>]*><\/script>/g,
|
|
9
|
+
endOffset: 10
|
|
10
|
+
},
|
|
11
|
+
stylesheet: {
|
|
12
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*stylesheet['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
13
|
+
endOffset: 1
|
|
14
|
+
},
|
|
15
|
+
modulepreload: {
|
|
16
|
+
regex: /<link[^<>]*['"]*rel['"]*=['"]*modulepreload['"]*[^<>]+['"]*href['"]*=['"]([^^ '"]+)['"][^<>]*>/g,
|
|
17
|
+
endOffset: 1
|
|
18
|
+
}
|
|
12
19
|
};
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
if (options.debug) {
|
|
16
|
-
console.log(`${LOG_PREFIX} ${message}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
21
|
+
const urlSupportCache = new Map();
|
|
19
22
|
|
|
20
|
-
function
|
|
23
|
+
function isUrlFromBypassDomain(url, bypassDomains = []) {
|
|
24
|
+
if (!url.startsWith('http')) return false
|
|
21
25
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error('Invalid content type');
|
|
29
|
-
}
|
|
30
|
-
return `${algorithm}-${hash.digest('base64')}`;
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.error(`${LOG_PREFIX} Failed to compute SRI hash: ${error}`);
|
|
33
|
-
return null;
|
|
26
|
+
const urlObj = new URL(url);
|
|
27
|
+
return bypassDomains.some(domain =>
|
|
28
|
+
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
|
29
|
+
)
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
async function
|
|
35
|
+
async function checkResourceSupport(url) {
|
|
36
|
+
if (urlSupportCache.has(url)) {
|
|
37
|
+
return urlSupportCache.get(url)
|
|
38
|
+
}
|
|
39
|
+
|
|
38
40
|
try {
|
|
39
41
|
const response = await fetch(url, {
|
|
40
|
-
method: 'HEAD'
|
|
42
|
+
method: 'HEAD',
|
|
43
|
+
timeout: 5000
|
|
41
44
|
});
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return false;
|
|
45
|
+
const corsHeader = response.headers.get('access-control-allow-origin');
|
|
46
|
+
const isSupported = response.ok && (corsHeader === '*' || corsHeader?.includes('*'));
|
|
47
|
+
urlSupportCache.set(url, isSupported);
|
|
48
|
+
return isSupported
|
|
49
|
+
} catch {
|
|
50
|
+
urlSupportCache.set(url, false);
|
|
51
|
+
return false
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function
|
|
54
|
-
if (!bypassDomains.length) return false;
|
|
55
|
+
async function fetchResource(url) {
|
|
55
56
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else if (hostname.startsWith('//')) {
|
|
63
|
-
hostname = hostname.slice(2);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
hostname = hostname.split('/')[0];
|
|
67
|
-
|
|
68
|
-
hostname = hostname.split(':')[0];
|
|
69
|
-
|
|
70
|
-
return bypassDomains.some(domain =>
|
|
71
|
-
hostname === domain || hostname.endsWith(`.${domain}`)
|
|
72
|
-
);
|
|
73
|
-
} catch (e) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function hasCrossOriginAttr(tag) {
|
|
79
|
-
return /(?:^|\s)crossorigin(?:=["']?[^"'\s>]*["']?)?(?:\s|>|$)/i.test(tag);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function getBundleKey(url, base = '') {
|
|
83
|
-
// Remove base prefix if exists
|
|
84
|
-
let cleanUrl = url.startsWith(base) ? url.slice(base.length) : url;
|
|
85
|
-
// Remove leading slash
|
|
86
|
-
cleanUrl = cleanUrl.startsWith('/') ? cleanUrl.slice(1) : cleanUrl;
|
|
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
|
-
]);
|
|
57
|
+
const response = await fetch(url, { timeout: 5000 });
|
|
58
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
59
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(`[vite-plugin-sri4] Failed to fetch external resource: ${url}`, error);
|
|
62
|
+
return null
|
|
103
63
|
}
|
|
104
|
-
|
|
105
|
-
return [...new Set(paths)];
|
|
106
64
|
}
|
|
107
65
|
|
|
108
|
-
function
|
|
109
|
-
const
|
|
110
|
-
let isBuild = false;
|
|
111
|
-
let base = '';
|
|
112
|
-
const htmlFiles = new Map(); // Store HTML file info for processing
|
|
113
|
-
const sriCache = new Map(); // Cache SRI hashes
|
|
66
|
+
function createTransformer(options, config) {
|
|
67
|
+
const { ignoreMissingAsset, bypassDomains, hashAlgorithm = 'sha384' } = options;
|
|
114
68
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
69
|
+
const getBundleKey = (htmlPath, url) => {
|
|
70
|
+
if (config.base === './' || config.base === '') {
|
|
71
|
+
return path.posix.resolve(htmlPath, url)
|
|
72
|
+
}
|
|
73
|
+
return url.replace(config.base, '')
|
|
74
|
+
};
|
|
119
75
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
log('Plugin configured in ' + (isBuild ? 'build' : 'dev') + ' mode', options);
|
|
125
|
-
},
|
|
76
|
+
const calculateIntegrity = async (bundle, htmlPath, url) => {
|
|
77
|
+
if (isUrlFromBypassDomain(url, bypassDomains)) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
126
80
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
81
|
+
let source;
|
|
82
|
+
if (url.startsWith('http')) {
|
|
83
|
+
const isSupported = await checkResourceSupport(url);
|
|
84
|
+
if (!isSupported) return null
|
|
85
|
+
source = await fetchResource(url);
|
|
86
|
+
if (!source) return null
|
|
87
|
+
} else {
|
|
88
|
+
const bundleItem = bundle[getBundleKey(htmlPath, url)];
|
|
89
|
+
if (!bundleItem) {
|
|
90
|
+
if (ignoreMissingAsset) return null
|
|
91
|
+
throw new Error(`Asset ${url} not found in bundle`)
|
|
130
92
|
}
|
|
93
|
+
source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
|
|
94
|
+
}
|
|
131
95
|
|
|
132
|
-
|
|
133
|
-
|
|
96
|
+
return `${hashAlgorithm}-${createHash(hashAlgorithm).update(source).digest('base64')}`
|
|
97
|
+
};
|
|
134
98
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
});
|
|
145
|
-
}
|
|
99
|
+
const transformHTML = async (bundle, htmlPath, html) => {
|
|
100
|
+
const changes = [];
|
|
146
101
|
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
const [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
102
|
+
for (const { regex, endOffset } of Object.values(HTML_PATTERNS)) {
|
|
103
|
+
const matches = [...html.matchAll(regex)];
|
|
104
|
+
for (const match of matches) {
|
|
105
|
+
const [, url] = match;
|
|
106
|
+
const end = match.index + match[0].length;
|
|
107
|
+
|
|
108
|
+
const integrity = await calculateIntegrity(bundle, htmlPath, url);
|
|
109
|
+
if (integrity) {
|
|
110
|
+
changes.push({
|
|
111
|
+
integrity,
|
|
112
|
+
position: end - endOffset
|
|
156
113
|
});
|
|
157
114
|
}
|
|
158
115
|
}
|
|
116
|
+
}
|
|
159
117
|
|
|
160
|
-
|
|
161
|
-
htmlFiles.set(ctx.filename, {
|
|
162
|
-
content: html,
|
|
163
|
-
resources: resourceTags
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
return html;
|
|
167
|
-
},
|
|
168
|
-
|
|
169
|
-
async writeBundle(options, bundle) {
|
|
170
|
-
for (const [filename, htmlInfo] of htmlFiles) {
|
|
171
|
-
let content = htmlInfo.content;
|
|
172
|
-
|
|
173
|
-
// Process all resources in parallel
|
|
174
|
-
const updates = await Promise.all(
|
|
175
|
-
htmlInfo.resources.map(async ({ tag, url, type }) => {
|
|
176
|
-
if (tag.includes('integrity=')) {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Handle external resources
|
|
181
|
-
if (/^(https?:)?\/\//i.test(url)) {
|
|
182
|
-
if (isBypassDomain(url, options.bypassDomains)) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Check cache first
|
|
187
|
-
if (sriCache.has(url)) {
|
|
188
|
-
return {
|
|
189
|
-
tag,
|
|
190
|
-
newTag: sriCache.get(url)
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const corsOk = await externalResourceIsCorsEnabled(url, options);
|
|
195
|
-
if (!corsOk) {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
try {
|
|
200
|
-
const response = await fetch(url);
|
|
201
|
-
const content = await response.arrayBuffer();
|
|
202
|
-
const hash = computeSri(Buffer.from(content), options.algorithm);
|
|
203
|
-
if (hash) {
|
|
204
|
-
const hasCrossOrigin = hasCrossOriginAttr(tag);
|
|
205
|
-
const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
|
|
206
|
-
const newTag = tag.replace(/>$/, ` integrity="${hash}"${crossOriginAttr}>`);
|
|
207
|
-
sriCache.set(url, newTag);
|
|
208
|
-
return { tag, newTag };
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
log(`Failed to process external resource ${url}: ${error}`, options);
|
|
212
|
-
}
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
118
|
+
changes.sort((a, b) => b.position - a.position);
|
|
215
119
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
120
|
+
for (const { integrity, position } of changes) {
|
|
121
|
+
const insertText = ` integrity="${integrity}"`;
|
|
122
|
+
html = html.slice(0, position) + insertText + html.slice(position);
|
|
123
|
+
}
|
|
219
124
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
bundleItem = bundle[key];
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
125
|
+
return html
|
|
126
|
+
};
|
|
226
127
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
console.warn(`${LOG_PREFIX} Asset not found in bundle: ${url}`);
|
|
230
|
-
}
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
128
|
+
return { transformHTML, calculateIntegrity }
|
|
129
|
+
}
|
|
233
130
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
131
|
+
function sri(options = {}) {
|
|
132
|
+
const {
|
|
133
|
+
ignoreMissingAsset = false,
|
|
134
|
+
bypassDomains = [],
|
|
135
|
+
hashAlgorithm = 'sha384'
|
|
136
|
+
} = options;
|
|
237
137
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
138
|
+
return {
|
|
139
|
+
name: 'vite-plugin-sri4',
|
|
140
|
+
enforce: 'post',
|
|
141
|
+
apply: 'build',
|
|
142
|
+
configResolved(config) {
|
|
143
|
+
const transformer = createTransformer({
|
|
144
|
+
ignoreMissingAsset,
|
|
145
|
+
bypassDomains,
|
|
146
|
+
hashAlgorithm
|
|
147
|
+
}, config);
|
|
148
|
+
|
|
149
|
+
const generateBundle = async function(_, bundle) {
|
|
150
|
+
const htmlFiles = Object.entries(bundle).filter(
|
|
151
|
+
([, chunk]) =>
|
|
152
|
+
chunk.type === 'asset' &&
|
|
153
|
+
/\.html?$/.test(chunk.fileName)
|
|
154
|
+
);
|
|
249
155
|
|
|
250
|
-
|
|
156
|
+
await Promise.all(
|
|
157
|
+
htmlFiles.map(async ([name, chunk]) => {
|
|
158
|
+
chunk.source = await transformer.transformHTML(bundle, name, chunk.source.toString());
|
|
251
159
|
})
|
|
252
160
|
);
|
|
161
|
+
};
|
|
253
162
|
|
|
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
|
-
}
|
|
163
|
+
const plugin = config.plugins.find(p => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN);
|
|
164
|
+
if (!plugin) {
|
|
165
|
+
throw new Error('vite-plugin-sri4 requires Vite 2.0.0 or higher')
|
|
265
166
|
}
|
|
266
167
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
168
|
+
if (typeof plugin.generateBundle === 'object' && plugin.generateBundle.handler) {
|
|
169
|
+
const originalHandler = plugin.generateBundle.handler;
|
|
170
|
+
plugin.generateBundle.handler = async function(...args) {
|
|
171
|
+
await originalHandler.apply(this, args);
|
|
172
|
+
await generateBundle.apply(this, args);
|
|
173
|
+
};
|
|
174
|
+
} else if (typeof plugin.generateBundle === 'function') {
|
|
175
|
+
const originalHandler = plugin.generateBundle;
|
|
176
|
+
plugin.generateBundle = async function(...args) {
|
|
177
|
+
await originalHandler.apply(this, args);
|
|
178
|
+
await generateBundle.apply(this, args);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
270
181
|
}
|
|
271
|
-
}
|
|
182
|
+
}
|
|
272
183
|
}
|
|
273
184
|
|
|
274
|
-
export { sri
|
|
185
|
+
export { sri };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-sri4",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|