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.
Files changed (3) hide show
  1. package/dist/index.cjs +141 -230
  2. package/dist/index.js +141 -230
  3. package/package.json +5 -2
package/dist/index.cjs CHANGED
@@ -1,276 +1,187 @@
1
1
  'use strict';
2
2
 
3
- var node_crypto = require('node:crypto');
3
+ var crypto = require('crypto');
4
+ var path = require('path');
4
5
  var fetch = require('cross-fetch');
5
6
 
6
- const LOG_PREFIX = '[vite-plugin-sri4]';
7
-
8
- const DEFAULT_OPTIONS = {
9
- algorithm: 'sha384',
10
- bypassDomains: [],
11
- crossorigin: 'anonymous',
12
- debug: false,
13
- ignoreMissingAsset: false
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
- function log(message, options) {
17
- if (options.debug) {
18
- console.log(`${LOG_PREFIX} ${message}`);
19
- }
20
- }
23
+ const urlSupportCache = new Map();
21
24
 
22
- function computeSri(content, algorithm = 'sha384') {
25
+ function isUrlFromBypassDomain(url, bypassDomains = []) {
26
+ if (!url.startsWith('http')) return false
23
27
  try {
24
- const hash = node_crypto.createHash(algorithm);
25
- if (Buffer.isBuffer(content) || content instanceof Uint8Array) {
26
- hash.update(content);
27
- } else if (typeof content === 'string') {
28
- hash.update(Buffer.from(content, 'utf-8'));
29
- } else {
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 externalResourceIsCorsEnabled(url, options) {
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 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;
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 isBypassDomain(url, bypassDomains = []) {
56
- if (!bypassDomains.length) return false;
57
+ async function fetchResource(url) {
57
58
  try {
58
- let hostname = url;
59
-
60
- if (hostname.startsWith('http://')) {
61
- hostname = hostname.slice(7);
62
- } else if (hostname.startsWith('https://')) {
63
- hostname = hostname.slice(8);
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 sri(userOptions = {}) {
111
- const options = { ...DEFAULT_OPTIONS, ...userOptions };
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
- return {
118
- name: 'vite-plugin-sri4',
119
- apply: 'build',
120
- enforce: 'post',
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
- configResolved(config) {
123
- options.domain = config.server?.host || '';
124
- isBuild = config.command === 'build';
125
- base = config.base || '';
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
- async transformIndexHtml(html, ctx) {
130
- if (!isBuild || !html) {
131
- return html;
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
- // Store HTML file info for later processing
135
- const resourceTags = [];
98
+ return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
99
+ };
136
100
 
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
- });
147
- }
101
+ const transformHTML = async (bundle, htmlPath, html) => {
102
+ const changes = [];
148
103
 
149
- // Find link tags
150
- const linkTagRegex = /<link[^>]+href=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
151
- while ((match = linkTagRegex.exec(html)) !== null) {
152
- const [tag, quotedUrl, unquotedUrl] = match;
153
- if (tag.includes('stylesheet') || tag.includes('modulepreload')) {
154
- resourceTags.push({
155
- tag,
156
- url: quotedUrl || unquotedUrl,
157
- type: 'link'
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
- // Store HTML file info
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
- // Handle local resources
219
- const possibleKeys = getBundleKey(url, base);
220
- let bundleItem = null;
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
- for (const key of possibleKeys) {
223
- if (bundle[key]) {
224
- bundleItem = bundle[key];
225
- break;
226
- }
227
- }
127
+ return html
128
+ };
228
129
 
229
- if (!bundleItem) {
230
- if (!options.ignoreMissingAsset) {
231
- console.warn(`${LOG_PREFIX} Asset not found in bundle: ${url}`);
232
- }
233
- return null;
234
- }
130
+ return { transformHTML, calculateIntegrity }
131
+ }
235
132
 
236
- try {
237
- const source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
238
- const integrity = computeSri(source, options.algorithm);
133
+ function sri(options = {}) {
134
+ const {
135
+ ignoreMissingAsset = false,
136
+ bypassDomains = [],
137
+ hashAlgorithm = 'sha384'
138
+ } = options;
239
139
 
240
- if (integrity) {
241
- const hasCrossOrigin = hasCrossOriginAttr(tag);
242
- const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
243
- const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
244
- return { tag, newTag };
245
- }
246
- } catch (error) {
247
- if (!options.ignoreMissingAsset) {
248
- console.error(`${LOG_PREFIX} Failed to process asset: ${url}`, error);
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
- return null;
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
- // Apply all updates to the HTML content
257
- updates.forEach(update => {
258
- if (update) {
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
- // Clear the caches
270
- htmlFiles.clear();
271
- sriCache.clear();
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
- module.exports = sri;
187
+ exports.sri = sri;
package/dist/index.js CHANGED
@@ -1,274 +1,185 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash } from 'crypto';
2
+ import path from 'path';
2
3
  import fetch from 'cross-fetch';
3
4
 
4
- const LOG_PREFIX = '[vite-plugin-sri4]';
5
-
6
- const DEFAULT_OPTIONS = {
7
- algorithm: 'sha384',
8
- bypassDomains: [],
9
- crossorigin: 'anonymous',
10
- debug: false,
11
- ignoreMissingAsset: false
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
- function log(message, options) {
15
- if (options.debug) {
16
- console.log(`${LOG_PREFIX} ${message}`);
17
- }
18
- }
21
+ const urlSupportCache = new Map();
19
22
 
20
- function computeSri(content, algorithm = 'sha384') {
23
+ function isUrlFromBypassDomain(url, bypassDomains = []) {
24
+ if (!url.startsWith('http')) return false
21
25
  try {
22
- const hash = createHash(algorithm);
23
- if (Buffer.isBuffer(content) || content instanceof Uint8Array) {
24
- hash.update(content);
25
- } else if (typeof content === 'string') {
26
- hash.update(Buffer.from(content, 'utf-8'));
27
- } else {
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 externalResourceIsCorsEnabled(url, options) {
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 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;
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 isBypassDomain(url, bypassDomains = []) {
54
- if (!bypassDomains.length) return false;
55
+ async function fetchResource(url) {
55
56
  try {
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
- }
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 sri(userOptions = {}) {
109
- const options = { ...DEFAULT_OPTIONS, ...userOptions };
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
- return {
116
- name: 'vite-plugin-sri4',
117
- apply: 'build',
118
- enforce: 'post',
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
- configResolved(config) {
121
- options.domain = config.server?.host || '';
122
- isBuild = config.command === 'build';
123
- base = config.base || '';
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
- async transformIndexHtml(html, ctx) {
128
- if (!isBuild || !html) {
129
- return html;
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
- // Store HTML file info for later processing
133
- const resourceTags = [];
96
+ return `${hashAlgorithm}-${createHash(hashAlgorithm).update(source).digest('base64')}`
97
+ };
134
98
 
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
- });
145
- }
99
+ const transformHTML = async (bundle, htmlPath, html) => {
100
+ const changes = [];
146
101
 
147
- // Find link tags
148
- const linkTagRegex = /<link[^>]+href=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
149
- while ((match = linkTagRegex.exec(html)) !== null) {
150
- const [tag, quotedUrl, unquotedUrl] = match;
151
- if (tag.includes('stylesheet') || tag.includes('modulepreload')) {
152
- resourceTags.push({
153
- tag,
154
- url: quotedUrl || unquotedUrl,
155
- type: 'link'
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
- // Store HTML file info
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
- // Handle local resources
217
- const possibleKeys = getBundleKey(url, base);
218
- let bundleItem = null;
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
- for (const key of possibleKeys) {
221
- if (bundle[key]) {
222
- bundleItem = bundle[key];
223
- break;
224
- }
225
- }
125
+ return html
126
+ };
226
127
 
227
- if (!bundleItem) {
228
- if (!options.ignoreMissingAsset) {
229
- console.warn(`${LOG_PREFIX} Asset not found in bundle: ${url}`);
230
- }
231
- return null;
232
- }
128
+ return { transformHTML, calculateIntegrity }
129
+ }
233
130
 
234
- try {
235
- const source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
236
- const integrity = computeSri(source, options.algorithm);
131
+ function sri(options = {}) {
132
+ const {
133
+ ignoreMissingAsset = false,
134
+ bypassDomains = [],
135
+ hashAlgorithm = 'sha384'
136
+ } = options;
237
137
 
238
- if (integrity) {
239
- const hasCrossOrigin = hasCrossOriginAttr(tag);
240
- const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
241
- const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
242
- return { tag, newTag };
243
- }
244
- } catch (error) {
245
- if (!options.ignoreMissingAsset) {
246
- console.error(`${LOG_PREFIX} Failed to process asset: ${url}`, error);
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
- return null;
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
- // Apply all updates to the HTML content
255
- updates.forEach(update => {
256
- if (update) {
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
- // Clear the caches
268
- htmlFiles.clear();
269
- sriCache.clear();
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 as default };
185
+ export { sri };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-sri4",
3
- "version": "1.8.7",
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",