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