vite-plugin-sri4 1.8.6 → 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 +138 -221
  2. package/dist/index.js +138 -221
  3. package/package.json +5 -2
package/dist/index.cjs CHANGED
@@ -1,270 +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
- log(`Failed to fetch CORS headers from ${url}: ${error}`, options);
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
- async function processTag(tag, url, options, bundle, base = '') {
111
- if (tag.includes('integrity=')) {
112
- log(`Skip tag with existing integrity attribute: ${tag}`, options);
113
- return tag;
114
- }
115
-
116
- log(`Processing tag: ${tag}`, options);
117
- log(`URL: ${url}`, options);
68
+ function createTransformer(options, config) {
69
+ const { ignoreMissingAsset, bypassDomains, hashAlgorithm = 'sha384' } = options;
118
70
 
119
- // Handle external resources
120
- if (/^(https?:)?\/\//i.test(url)) {
121
- if (isBypassDomain(url, options.bypassDomains)) {
122
- log(`Skip SRI for bypass domain: ${url}`, options);
123
- return tag;
71
+ const getBundleKey = (htmlPath, url) => {
72
+ if (config.base === './' || config.base === '') {
73
+ return path.posix.resolve(htmlPath, url)
124
74
  }
75
+ return url.replace(config.base, '')
76
+ };
125
77
 
126
- const corsOk = await externalResourceIsCorsEnabled(url, options);
127
- if (!corsOk) {
128
- log(`External resource ${url} does not support CORS`, options);
129
- return tag;
78
+ const calculateIntegrity = async (bundle, htmlPath, url) => {
79
+ if (isUrlFromBypassDomain(url, bypassDomains)) {
80
+ return null
130
81
  }
131
82
 
132
- try {
133
- const response = await fetch(url);
134
- const content = await response.arrayBuffer();
135
- const hash = computeSri(Buffer.from(content), options.algorithm);
136
- if (hash) {
137
- log(`Computing SRI for external resource ${url}: ${hash}`, options);
138
- const hasCrossOrigin = hasCrossOriginAttr(tag);
139
- const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
140
- const newTag = tag.replace(/>$/, ` integrity="${hash}"${crossOriginAttr}>`);
141
- log(`New tag: ${newTag}`, options);
142
- return newTag;
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`)
143
94
  }
144
- } catch (error) {
145
- log(`Failed to process external resource ${url}: ${error}`, options);
95
+ source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
146
96
  }
147
- return tag;
148
- }
149
-
150
- // Handle local resources
151
- const possibleKeys = getBundleKey(url, base);
152
- let bundleItem = null;
153
- let source;
154
-
155
- log(`Looking for bundle keys:`, options);
156
- possibleKeys.forEach(key => log(`- ${key}`, options));
157
97
 
158
- for (const key of possibleKeys) {
159
- if (bundle[key]) {
160
- bundleItem = bundle[key];
161
- log(`Found bundle item for key: ${key}`, options);
162
- break;
163
- }
164
- }
98
+ return `${hashAlgorithm}-${crypto.createHash(hashAlgorithm).update(source).digest('base64')}`
99
+ };
165
100
 
166
- if (!bundleItem) {
167
- log(`Bundle item not found for ${url}`, options);
168
- if (!options.ignoreMissingAsset) {
169
- console.warn(`${LOG_PREFIX} Asset not found in bundle: ${url}`);
170
- log(`Available bundle keys:`, options);
171
- Object.keys(bundle).forEach(key => log(`- ${key}`, options));
172
- } else {
173
- log(`Ignoring missing asset due to ignoreMissingAsset option`, options);
101
+ const transformHTML = async (bundle, htmlPath, html) => {
102
+ const changes = [];
103
+
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
115
+ });
116
+ }
117
+ }
174
118
  }
175
- return tag;
176
- }
177
119
 
178
- log(`Bundle item type: ${bundleItem.type}`, options);
120
+ changes.sort((a, b) => b.position - a.position);
179
121
 
180
- try {
181
- if (bundleItem.type === 'chunk') {
182
- source = bundleItem.code;
183
- log(`Processing chunk content of length: ${source.length}`, options);
184
- } else {
185
- source = bundleItem.source;
186
- log(`Processing asset content of length: ${source.length}`, options);
122
+ for (const { integrity, position } of changes) {
123
+ const insertText = ` integrity="${integrity}"`;
124
+ html = html.slice(0, position) + insertText + html.slice(position);
187
125
  }
188
126
 
189
- const integrity = computeSri(source, options.algorithm);
190
- if (integrity) {
191
- log(`Computing SRI for local resource ${url}: ${integrity}`, options);
192
- const hasCrossOrigin = hasCrossOriginAttr(tag);
193
- const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
194
- const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
195
- log(`New tag: ${newTag}`, options);
196
- return newTag;
197
- }
198
- } catch (error) {
199
- log(`Error processing bundle item: ${error}`, options);
200
- if (!options.ignoreMissingAsset) {
201
- console.error(`${LOG_PREFIX} Failed to process asset: ${url}`, error);
202
- }
203
- }
127
+ return html
128
+ };
204
129
 
205
- return tag;
130
+ return { transformHTML, calculateIntegrity }
206
131
  }
207
132
 
208
- function sri(userOptions = {}) {
209
- const options = { ...DEFAULT_OPTIONS, ...userOptions };
210
- let isBuild = false;
211
- let base = '';
133
+ function sri(options = {}) {
134
+ const {
135
+ ignoreMissingAsset = false,
136
+ bypassDomains = [],
137
+ hashAlgorithm = 'sha384'
138
+ } = options;
212
139
 
213
140
  return {
214
141
  name: 'vite-plugin-sri4',
215
- apply: 'build',
216
142
  enforce: 'post',
217
-
143
+ apply: 'build',
218
144
  configResolved(config) {
219
- options.domain = config.server?.host || '';
220
- isBuild = config.command === 'build';
221
- base = config.base || '';
222
- log('Plugin configured in ' + (isBuild ? 'build' : 'dev') + ' mode', options);
223
- log(`Base URL: ${base}`, options);
224
- log(`ignoreMissingAsset: ${options.ignoreMissingAsset}`, options);
225
- },
226
-
227
- async transformIndexHtml(html, ctx) {
228
- if (!isBuild || !html) {
229
- log('Skipping HTML transform in dev mode or empty HTML', options);
230
- return html;
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
+ );
157
+
158
+ await Promise.all(
159
+ htmlFiles.map(async ([name, chunk]) => {
160
+ chunk.source = await transformer.transformHTML(bundle, name, chunk.source.toString());
161
+ })
162
+ );
163
+ };
164
+
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')
231
168
  }
232
169
 
233
- try {
234
- log('Starting HTML transformation', options);
235
- const bundle = ctx.bundle || {};
236
- log(`Bundle size: ${Object.keys(bundle).length}`, options);
237
-
238
- // Process script tags with and without quotes
239
- const scriptTagRegex = /<script[^>]+src=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
240
- let match;
241
- while ((match = scriptTagRegex.exec(html)) !== null) {
242
- const [tag, quotedUrl, unquotedUrl] = match;
243
- const url = quotedUrl || unquotedUrl;
244
- log(`Processing script: ${url}`, options);
245
- const newTag = await processTag(tag, url, options, bundle, base);
246
- html = html.replace(tag, newTag);
247
- }
248
-
249
- // Process link tags with and without quotes
250
- const linkTagRegex = /<link[^>]+href=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
251
- while ((match = linkTagRegex.exec(html)) !== null) {
252
- const [tag, quotedUrl, unquotedUrl] = match;
253
- const url = quotedUrl || unquotedUrl;
254
- if (tag.includes('stylesheet') || tag.includes('modulepreload')) {
255
- log(`Processing link: ${url}`, options);
256
- const newTag = await processTag(tag, url, options, bundle, base);
257
- html = html.replace(tag, newTag);
258
- }
259
- }
260
-
261
- return html;
262
- } catch (error) {
263
- console.error(`${LOG_PREFIX} Failed to transform HTML: ${error}`);
264
- return html;
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
+ };
265
182
  }
266
183
  }
267
- };
184
+ }
268
185
  }
269
186
 
270
- module.exports = sri;
187
+ exports.sri = sri;
package/dist/index.js CHANGED
@@ -1,268 +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
- log(`Failed to fetch CORS headers from ${url}: ${error}`, options);
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
- async function processTag(tag, url, options, bundle, base = '') {
109
- if (tag.includes('integrity=')) {
110
- log(`Skip tag with existing integrity attribute: ${tag}`, options);
111
- return tag;
112
- }
113
-
114
- log(`Processing tag: ${tag}`, options);
115
- log(`URL: ${url}`, options);
66
+ function createTransformer(options, config) {
67
+ const { ignoreMissingAsset, bypassDomains, hashAlgorithm = 'sha384' } = options;
116
68
 
117
- // Handle external resources
118
- if (/^(https?:)?\/\//i.test(url)) {
119
- if (isBypassDomain(url, options.bypassDomains)) {
120
- log(`Skip SRI for bypass domain: ${url}`, options);
121
- return tag;
69
+ const getBundleKey = (htmlPath, url) => {
70
+ if (config.base === './' || config.base === '') {
71
+ return path.posix.resolve(htmlPath, url)
122
72
  }
73
+ return url.replace(config.base, '')
74
+ };
123
75
 
124
- const corsOk = await externalResourceIsCorsEnabled(url, options);
125
- if (!corsOk) {
126
- log(`External resource ${url} does not support CORS`, options);
127
- return tag;
76
+ const calculateIntegrity = async (bundle, htmlPath, url) => {
77
+ if (isUrlFromBypassDomain(url, bypassDomains)) {
78
+ return null
128
79
  }
129
80
 
130
- try {
131
- const response = await fetch(url);
132
- const content = await response.arrayBuffer();
133
- const hash = computeSri(Buffer.from(content), options.algorithm);
134
- if (hash) {
135
- log(`Computing SRI for external resource ${url}: ${hash}`, options);
136
- const hasCrossOrigin = hasCrossOriginAttr(tag);
137
- const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
138
- const newTag = tag.replace(/>$/, ` integrity="${hash}"${crossOriginAttr}>`);
139
- log(`New tag: ${newTag}`, options);
140
- return newTag;
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`)
141
92
  }
142
- } catch (error) {
143
- log(`Failed to process external resource ${url}: ${error}`, options);
93
+ source = bundleItem.type === 'chunk' ? bundleItem.code : bundleItem.source;
144
94
  }
145
- return tag;
146
- }
147
-
148
- // Handle local resources
149
- const possibleKeys = getBundleKey(url, base);
150
- let bundleItem = null;
151
- let source;
152
-
153
- log(`Looking for bundle keys:`, options);
154
- possibleKeys.forEach(key => log(`- ${key}`, options));
155
95
 
156
- for (const key of possibleKeys) {
157
- if (bundle[key]) {
158
- bundleItem = bundle[key];
159
- log(`Found bundle item for key: ${key}`, options);
160
- break;
161
- }
162
- }
96
+ return `${hashAlgorithm}-${createHash(hashAlgorithm).update(source).digest('base64')}`
97
+ };
163
98
 
164
- if (!bundleItem) {
165
- log(`Bundle item not found for ${url}`, options);
166
- if (!options.ignoreMissingAsset) {
167
- console.warn(`${LOG_PREFIX} Asset not found in bundle: ${url}`);
168
- log(`Available bundle keys:`, options);
169
- Object.keys(bundle).forEach(key => log(`- ${key}`, options));
170
- } else {
171
- log(`Ignoring missing asset due to ignoreMissingAsset option`, options);
99
+ const transformHTML = async (bundle, htmlPath, html) => {
100
+ const changes = [];
101
+
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
113
+ });
114
+ }
115
+ }
172
116
  }
173
- return tag;
174
- }
175
117
 
176
- log(`Bundle item type: ${bundleItem.type}`, options);
118
+ changes.sort((a, b) => b.position - a.position);
177
119
 
178
- try {
179
- if (bundleItem.type === 'chunk') {
180
- source = bundleItem.code;
181
- log(`Processing chunk content of length: ${source.length}`, options);
182
- } else {
183
- source = bundleItem.source;
184
- log(`Processing asset content of length: ${source.length}`, options);
120
+ for (const { integrity, position } of changes) {
121
+ const insertText = ` integrity="${integrity}"`;
122
+ html = html.slice(0, position) + insertText + html.slice(position);
185
123
  }
186
124
 
187
- const integrity = computeSri(source, options.algorithm);
188
- if (integrity) {
189
- log(`Computing SRI for local resource ${url}: ${integrity}`, options);
190
- const hasCrossOrigin = hasCrossOriginAttr(tag);
191
- const crossOriginAttr = hasCrossOrigin ? '' : ` crossorigin="${options.crossorigin}"`;
192
- const newTag = tag.replace(/>$/, ` integrity="${integrity}"${crossOriginAttr}>`);
193
- log(`New tag: ${newTag}`, options);
194
- return newTag;
195
- }
196
- } catch (error) {
197
- log(`Error processing bundle item: ${error}`, options);
198
- if (!options.ignoreMissingAsset) {
199
- console.error(`${LOG_PREFIX} Failed to process asset: ${url}`, error);
200
- }
201
- }
125
+ return html
126
+ };
202
127
 
203
- return tag;
128
+ return { transformHTML, calculateIntegrity }
204
129
  }
205
130
 
206
- function sri(userOptions = {}) {
207
- const options = { ...DEFAULT_OPTIONS, ...userOptions };
208
- let isBuild = false;
209
- let base = '';
131
+ function sri(options = {}) {
132
+ const {
133
+ ignoreMissingAsset = false,
134
+ bypassDomains = [],
135
+ hashAlgorithm = 'sha384'
136
+ } = options;
210
137
 
211
138
  return {
212
139
  name: 'vite-plugin-sri4',
213
- apply: 'build',
214
140
  enforce: 'post',
215
-
141
+ apply: 'build',
216
142
  configResolved(config) {
217
- options.domain = config.server?.host || '';
218
- isBuild = config.command === 'build';
219
- base = config.base || '';
220
- log('Plugin configured in ' + (isBuild ? 'build' : 'dev') + ' mode', options);
221
- log(`Base URL: ${base}`, options);
222
- log(`ignoreMissingAsset: ${options.ignoreMissingAsset}`, options);
223
- },
224
-
225
- async transformIndexHtml(html, ctx) {
226
- if (!isBuild || !html) {
227
- log('Skipping HTML transform in dev mode or empty HTML', options);
228
- return html;
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
+ );
155
+
156
+ await Promise.all(
157
+ htmlFiles.map(async ([name, chunk]) => {
158
+ chunk.source = await transformer.transformHTML(bundle, name, chunk.source.toString());
159
+ })
160
+ );
161
+ };
162
+
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')
229
166
  }
230
167
 
231
- try {
232
- log('Starting HTML transformation', options);
233
- const bundle = ctx.bundle || {};
234
- log(`Bundle size: ${Object.keys(bundle).length}`, options);
235
-
236
- // Process script tags with and without quotes
237
- const scriptTagRegex = /<script[^>]+src=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
238
- let match;
239
- while ((match = scriptTagRegex.exec(html)) !== null) {
240
- const [tag, quotedUrl, unquotedUrl] = match;
241
- const url = quotedUrl || unquotedUrl;
242
- log(`Processing script: ${url}`, options);
243
- const newTag = await processTag(tag, url, options, bundle, base);
244
- html = html.replace(tag, newTag);
245
- }
246
-
247
- // Process link tags with and without quotes
248
- const linkTagRegex = /<link[^>]+href=(?:["']([^"']+)["']|([^ >]+))[^>]*>/g;
249
- while ((match = linkTagRegex.exec(html)) !== null) {
250
- const [tag, quotedUrl, unquotedUrl] = match;
251
- const url = quotedUrl || unquotedUrl;
252
- if (tag.includes('stylesheet') || tag.includes('modulepreload')) {
253
- log(`Processing link: ${url}`, options);
254
- const newTag = await processTag(tag, url, options, bundle, base);
255
- html = html.replace(tag, newTag);
256
- }
257
- }
258
-
259
- return html;
260
- } catch (error) {
261
- console.error(`${LOG_PREFIX} Failed to transform HTML: ${error}`);
262
- return html;
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
+ };
263
180
  }
264
181
  }
265
- };
182
+ }
266
183
  }
267
184
 
268
- 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.6",
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",