vite-plugin-sri4 1.8.7 → 2.0.0

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