UNPKG

vite-plugin-component-instrumentation

Version:

A professional Vite plugin that automatically adds identifying data attributes to React components for debugging, testing, and analytics

770 lines (705 loc) 28.9 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var parser = require('@babel/parser'); var MagicString = require('magic-string'); var path = require('path'); var estreeWalker = require('estree-walker'); /** * Default configuration */ const DEFAULT_OPTIONS = { attributePrefix: 'data-ct', extensions: ['.jsx', '.tsx'], enableInProduction: false, excludePatterns: ['node_modules'], includeTagName: true, customIdGenerator: (fileRelative, loc) => `${fileRelative}:${loc.line}:${loc.column}`, debug: false, trackState: 'off', allowKeys: [], maxAttrLength: 160, throttleMs: 120, showFullObjects: false, }; /** * Exported function for testing state summarization logic */ function buildSummaryDev(state, config) { if (state == null) return ''; if (typeof state !== 'object') return String(state); const showFull = config.showFullObjects || false; const maxAttr = config.maxAttrLength || 160; const allowSet = new Set(config.allowKeys || []); // Security filtering const SENSITIVE_KEYS = /(password|passwd|pwd|token|secret|key|auth|bearer|session|cookie|ssid|ssn|credit|card|cvv|pin|otp)/i; const BAD_KEYS = showFull ? SENSITIVE_KEYS : /(token|pass|secret|auth|email|cookie|session|ssid|ssn|phone|address|credit|card|api[-_]?key|bearer)/i; function isPrim(v) { const t = typeof v; return v == null || t === 'boolean' || t === 'number' || (t === 'string' && v.length <= 32); } function summarize(v, showFullNested) { const t = typeof v; if (v == null || t === 'boolean' || t === 'number') return String(v); if (t === 'string') { if (showFullNested) return JSON.stringify(v); if (v.length <= 32) return v; // Don't quote short strings at top level return 'str:' + v.length; } if (Array.isArray(v)) { if (showFullNested && v.length <= 5) { const items = v.map(item => summarize(item, false)); return '[' + items.join(',') + ']'; } return 'arr:' + v.length + 'items'; } if (t === 'object') { const allKeys = Object.keys(v || {}); const safeKeys = allKeys.filter(k => !BAD_KEYS.test(k)); if (showFullNested && safeKeys.length <= 8) { const pairs = safeKeys.map(k => { const val = v[k]; const valStr = summarize(val, true); // Pass true for nested objects return k + ':' + valStr; }); const filteredCount = allKeys.length - safeKeys.length; if (filteredCount > 0) { pairs.push('...' + filteredCount + 'hidden'); } return '{' + pairs.join(',') + '}'; } return 'obj:' + safeKeys.length + 'keys' + (allKeys.length > safeKeys.length ? '+hidden' : ''); } return t; } const out = []; let count = 0; let truncated = false; const keys = Object.keys(state); for (const k of keys) { const v = state[k]; // Skip only truly sensitive keys (unless explicitly allowed) if (BAD_KEYS.test(k) && !allowSet.has(k)) { continue; } let val; if (allowSet.has(k)) { // Always show allowed keys in full if requested val = showFull ? summarize(v, true) : (isPrim(v) ? String(v) : summarize(v, false)); } else if (isPrim(v)) { val = String(v); } else { val = summarize(v, showFull); } out.push(k + '=' + val); count++; // Check length more frequently when showing full objects const currentStr = out.join(','); if (currentStr.length > maxAttr) { // Try to fit by removing the last item and mark truncated out.pop(); truncated = true; break; } if (count >= 12) break; } let s = out.join(','); if (s.length > maxAttr) { // Truncate and add ellipsis return s.slice(0, maxAttr - 3) + '...'; } if (truncated) { // We dropped items; indicate truncation while respecting limit if (s.length + 3 > maxAttr) return s.slice(0, maxAttr - 3) + '...'; return s + '...'; } return s; } /** * Dynamically calculates the correct line number by reading the original source file * This eliminates the need for magic number offsets and provides 100% accuracy */ async function calculateAccurateLineNumber(filePath, astLine, node) { try { // Use ES module file reading instead of CommonJS require const { readFileSync, existsSync } = await import('fs'); const { resolve } = await import('path'); // Convert the file path to an absolute path const absolutePath = resolve(process.cwd(), filePath); if (!existsSync(absolutePath)) { return astLine; } const originalSource = readFileSync(absolutePath, 'utf8'); const lines = originalSource.split('\n'); // Strategy 1: Look for the exact JSX tag in the original source const tagName = node.name?.name; if (tagName) { // Search through the entire original source to find the JSX tag for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Look for the JSX opening tag if (line.includes(`<${tagName}`) || line.includes(`<${tagName} `)) { return i + 1; // Convert to 1-based indexing } } } // Strategy 2: Look for the return statement that contains this JSX // Find the function/component that contains this JSX element let functionStart = -1; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes('function') || line.includes('=>') || line.includes('class')) { functionStart = i; break; } } if (functionStart !== -1) { // Look for the return statement within this function for (let i = functionStart; i < lines.length; i++) { const line = lines[i]; if (line.includes('return') && line.includes('(')) { // This is the return statement - the JSX should be on the next line or close return i + 1; } } } // Strategy 3: Look for JSX patterns in the original source for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes('<') && line.includes('>')) { return i + 1; } } } catch (error) { // Silently fallback to AST line number } // Fallback: if all else fails, use the AST line return astLine; } // Shared runtime base for both events and summary modes const STATE_RUNTIME_BASE = ` // Shared utilities and hook management var HOOK_NAME_CACHE = typeof WeakMap === 'function' ? new WeakMap() : null; var LAST_SUMMARY_CACHE = typeof WeakMap === 'function' ? new WeakMap() : null; var LAST_UPDATE_CACHE = typeof WeakMap === 'function' ? new WeakMap() : null; function isHost(f) { return f && (f.tag === 5 || f.tag === 6) && f.stateNode instanceof Element; } function findHostNode(fiber) { var f = fiber; while (f && !isHost(f)) f = f.child || f.sibling; if (isHost(f)) return f.stateNode; f = fiber; while (f && !isHost(f)) f = f.return; return isHost(f) ? f.stateNode : null; } function walk(fiber, visit) { try { visit(fiber); } catch(e) {} var child = fiber.child; while (child) { walk(child, visit); child = child.sibling; } } function setupHookPatch(onCommitHandler) { var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; if (!hook) { hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { renderers: new Map(), supportsFiber: true, inject: function(renderer) { var id = Math.floor(Math.random() * 1e9); this.renderers.set(id, renderer); return id; } }; } var prev = typeof hook.onCommitFiberRoot === 'function' ? hook.onCommitFiberRoot.bind(hook) : null; hook.onCommitFiberRoot = function(rendererID, root) { var rest = Array.prototype.slice.call(arguments, 2); try { var current = root && root.current; if (current) { onCommitHandler(current); } } catch(e) { console.warn('[component-instrumentation] Error in commit processing:', e); } return prev ? prev.apply(this, [rendererID, root].concat(rest)) : undefined; }; } function createScheduler(cfg, flushFn) { var pending = []; var scheduled = false; var throttleMs = (cfg && cfg.throttleMs) || 120; function scheduleFlush() { if (scheduled) return; scheduled = true; var scheduler = window.requestIdleCallback || function(cb) { return setTimeout(cb, throttleMs); }; scheduler(flush); } function flush() { scheduled = false; if (!pending.length) return; var maxItems = (cfg && cfg.maxItemsPerFlush) || 200; var items = pending.splice(0, maxItems); flushFn(items); // Reschedule if more items remain if (pending.length > 0) { scheduleFlush(); } } function enqueue(item) { pending.push(item); scheduleFlush(); } return { enqueue: enqueue, flush: flush }; } function setupMutationObserver(attrs, enqueueCallback) { try { var mo = new MutationObserver(function(recs) { var now = Date.now(); for (var i = 0; i < recs.length; i++) { var r = recs[i]; var nodes = []; if (r.type === 'childList') { if (r.addedNodes) { for (var j = 0; j < r.addedNodes.length; j++) { nodes.push(r.addedNodes[j]); } } nodes.push(r.target); } else if (r.type === 'characterData') { nodes.push(r.target.parentElement); } for (var k = 0; k < nodes.length; k++) { var n = nodes[k]; var el = (n instanceof Element) ? n : (n && n.parentElement); var cur = el; while (cur && cur instanceof Element) { var id = null; for (var a = 0; a < attrs.length; a++) { var v = cur.getAttribute(attrs[a]); if (v) { id = v; break; } } if (id) { // Throttle updates per element if (!LAST_UPDATE_CACHE || !LAST_UPDATE_CACHE.get(cur) || (now - LAST_UPDATE_CACHE.get(cur)) > 50) { if (LAST_UPDATE_CACHE) LAST_UPDATE_CACHE.set(cur, now); enqueueCallback(cur, id); } break; } cur = cur.parentElement; } } } }); mo.observe(document.documentElement, { childList: true, characterData: true, subtree: true }); // Cleanup on unload try { window.addEventListener('unload', function() { try { mo.disconnect(); } catch(e) {} }); } catch(e) {} return mo; } catch(e) { console.warn('[component-instrumentation] MutationObserver setup failed:', e); return null; } } `; // Dev-only browser code for state events virtual module const STATE_EVENTS_SOURCE = STATE_RUNTIME_BASE + ` export function setupStateEvents() { var cfg = { throttleMs: 120, maxItemsPerFlush: 200 }; var seen = new Set(); function flushEvents(items) { if (items.length === 0) return; window.dispatchEvent(new CustomEvent('ct:state', { detail: { ts: Date.now(), items: items } })); } var scheduler = createScheduler(cfg, flushEvents); function handleCommit(current) { walk(current, function(fiber) { var node = findHostNode(fiber); if (!node) return; var id = node.getAttribute && (node.getAttribute('data-ct-id') || node.getAttribute('data-debug-id')); if (!id) return; var name = node.getAttribute('data-ct-name') || node.getAttribute('data-debug-name'); scheduler.enqueue({ id: id, name: name || undefined }); }); } setupHookPatch(handleCommit); var ATTRS = ['data-ct-id', 'data-debug-id']; setupMutationObserver(ATTRS, function(element, id) { if (!seen.has(id)) { seen.add(id); var name = element.getAttribute('data-ct-name') || element.getAttribute('data-debug-name'); scheduler.enqueue({ id: id, name: name || undefined }); } }); }`; // Dev-only browser code for summary runtime virtual module const STATE_SUMMARY_SOURCE = STATE_RUNTIME_BASE + ` export function setupStateSummary(cfg){ var MAX_ATTR = (cfg && cfg.maxAttrLength) || 160; var ALLOW = new Set((cfg && cfg.allowKeys) || []); var SHOW_FULL = (cfg && cfg.showFullObjects) || false; // Security filtering var SENSITIVE_KEYS = /(password|passwd|pwd|token|secret|key|auth|bearer|session|cookie|ssid|ssn|credit|card|cvv|pin|otp)/i; var BAD_KEYS = SHOW_FULL ? SENSITIVE_KEYS : /(token|pass|secret|auth|email|cookie|session|ssid|ssn|phone|address|credit|card|api[-_]?key|bearer)/i; function isPrim(v){ var t=typeof v; return v==null || t==='boolean' || t==='number' || (t==='string' && v.length<=32); } function summarize(v, showFullNested){ var t = typeof v; if (v==null || t==='boolean' || t==='number') return String(v); if (t==='string') { if (showFullNested || v.length <= 32) return JSON.stringify(v); return 'str:'+v.length; } if (Array.isArray(v)) { if (showFullNested && v.length <= 5) { var items = []; for (var i = 0; i < v.length; i++) { items.push(summarize(v[i], false)); } return '[' + items.join(',') + ']'; } return 'arr:' + v.length + 'items'; } if (t==='object') { var allKeys = Object.keys(v||{}); var safeKeys = allKeys.filter(function(k){ return !BAD_KEYS.test(k); }); if (showFullNested && safeKeys.length <= 8) { var pairs = []; for (var j = 0; j < safeKeys.length; j++) { var k = safeKeys[j]; var val = v[k]; var valStr = summarize(val, false); pairs.push(k + ':' + valStr); } var filteredCount = allKeys.length - safeKeys.length; if (filteredCount > 0) { pairs.push('...' + filteredCount + 'hidden'); } return '{' + pairs.join(',') + '}'; } return 'obj:' + safeKeys.length + 'keys' + (allKeys.length > safeKeys.length ? '+hidden' : ''); } return t; } function buildSummary(state){ if (state==null) return ''; if (typeof state!=='object') return String(state); var out = []; var count = 0; var keys = Object.keys(state); for (var i=0;i<keys.length;i++){ var k = keys[i]; var v = state[k]; var val; // Skip only truly sensitive keys (unless explicitly allowed) if (BAD_KEYS.test(k) && !ALLOW.has(k)) { continue; } if (ALLOW.has(k)) { // Always show allowed keys in full if requested val = SHOW_FULL ? summarize(v, true) : (isPrim(v) ? v : summarize(v, false)); } else if (isPrim(v)) { val = v; } else { val = summarize(v, SHOW_FULL); } out.push(k+'='+String(val)); count++; // Check length more frequently when showing full objects var currentStr = out.join(','); if (currentStr.length > MAX_ATTR) { // Try to fit by removing the last item and truncating out.pop(); break; } if (count>=12) break; } var s = out.join(','); return s.length>MAX_ATTR ? s.slice(0, MAX_ATTR-3) + '...' : s; } function flushSummaries(items) { console.log('[component-instrumentation] Flushing', items.length, 'state summaries'); for (var i = 0; i < items.length; i++) { var it = items[i]; try { var attrName = (cfg && cfg.attrName) || 'data-ct-state'; // Skip no-op updates using WeakMap cache if (LAST_SUMMARY_CACHE && LAST_SUMMARY_CACHE.get(it.node) === it.summary) { continue; } it.node.setAttribute(attrName, it.summary); if (LAST_SUMMARY_CACHE) LAST_SUMMARY_CACHE.set(it.node, it.summary); console.log('[component-instrumentation] Set', attrName + '="' + it.summary + '"', 'on', it.node); } catch(e) { console.warn('[component-instrumentation] Failed to set attribute:', e); } } } var scheduler = createScheduler(cfg, flushSummaries); function enqueue(node, state) { var summary = buildSummary(state); if (!summary) return; scheduler.enqueue({ node: node, summary: summary }); console.log('[component-instrumentation] Enqueued state summary:', summary, 'for node:', node); } function fiberState(fiber){ try { if (!fiber) return null; // Try to extract real variable names using React DevTools/Refresh techniques var realNames = extractHookNames(fiber); // For function components, memoizedState is the hooks chain if (fiber.memoizedState && typeof fiber.memoizedState === 'object') { var state = {}; var hook = fiber.memoizedState; var index = 0; while (hook && index < 10) { // Limit to prevent infinite loops if (hook.memoizedState !== undefined) { var keyName = realNames[index] || ('state' + index); state[keyName] = hook.memoizedState; } hook = hook.next; index++; } return Object.keys(state).length > 0 ? state : null; } return null; } catch(e){ return null; } } function extractHookNames(fiber) { // Use WeakMap cache to avoid repeated parsing if (HOOK_NAME_CACHE && fiber.type && HOOK_NAME_CACHE.has(fiber.type)) { return HOOK_NAME_CACHE.get(fiber.type); } var names = {}; try { // Method 1: Check React Refresh signature (most reliable) if (fiber.type && fiber.type._signature) { var sig = fiber.type._signature; if (sig.getCustomHooks) { var hooks = sig.getCustomHooks(); if (hooks && hooks.length > 0) { for (var i = 0; i < hooks.length; i++) { var hookInfo = hooks[i]; if (hookInfo && hookInfo.key) { // Parse "useState{[count, setCount]}(0)" format var match = hookInfo.key.match(/^([^{]+)\\{\\[([^,]+)/); if (match && match[2]) { names[i] = match[2].trim(); } } } } } } // Method 2: Parse component source if available (only when showFullObjects is enabled) if (SHOW_FULL && fiber.type && fiber.type.toString) { var source = fiber.type.toString(); var hookMatches = source.match(/const\\s*\\[\\s*([^,\\]]+)/g); if (hookMatches) { for (var j = 0; j < hookMatches.length && j < 5; j++) { var varMatch = hookMatches[j].match(/const\\s*\\[\\s*([^,\\]]+)/); if (varMatch && varMatch[1]) { names[j] = names[j] || varMatch[1].trim(); } } } } // Method 3: Fallback to smart naming var componentName = (fiber.type && fiber.type.name) || ''; if (componentName === 'Counter') names[0] = names[0] || 'count'; if (componentName === 'Form') names[0] = names[0] || 'formData'; } catch(e) { console.warn('[component-instrumentation] Hook name extraction failed:', e); } // Cache the result if (HOOK_NAME_CACHE && fiber.type) { HOOK_NAME_CACHE.set(fiber.type, names); } return names; } function handleCommit(current) { walk(current, function(fiber) { // Only process function components with hooks if (fiber.tag !== 0 && fiber.tag !== 1) return; // 0=FunctionComponent, 1=ClassComponent var node = findHostNode(fiber); if (!node) return; var hasId = node.getAttribute && (node.getAttribute('data-ct-id') || node.getAttribute('data-debug-id')); if (!hasId) return; var st = fiberState(fiber); if (st) enqueue(node, st); }); } setupHookPatch(handleCommit); var ATTRS = ['data-ct-id', 'data-debug-id']; setupMutationObserver(ATTRS, function(element, id) { // Create a simple state placeholder for MutationObserver updates enqueue(element, { updated: Date.now() }); }); console.log('[component-instrumentation] MutationObserver fallback active'); }`; /** * Creates a Vite plugin that provides comprehensive React component instrumentation * for debugging, testing, analytics, and observability */ function componentInstrumentation(userOptions = {}) { const options = { ...DEFAULT_OPTIONS, ...userOptions }; const validExtensions = new Set(options.extensions); const log = options.debug ? (message, ...args) => console.log(`[component-instrumentation] ${message}`, ...args) : () => { }; return { name: 'vite-plugin-component-instrumentation', apply: options.enableInProduction ? 'build' : 'serve', enforce: 'pre', resolveId(id) { if (options.trackState !== 'off') { if (id === '/__ct_state_events__' || id === '/__ct_state_events_code__' || id === '/__ct_state_events__.js' || id === '/__ct_state_events_code__.js' || id === '/__ct_state_summary__' || id === '/__ct_state_summary_code__' || id === '/__ct_state_summary__.js' || id === '/__ct_state_summary_code__.js') return id; } return null; }, load(id) { if (options.trackState === 'events' && (id === '/__ct_state_events__' || id === '/__ct_state_events__.js')) { return `import { setupStateEvents } from '/__ct_state_events_code__.js';\nsetupStateEvents();`; } if (options.trackState === 'events' && (id === '/__ct_state_events_code__' || id === '/__ct_state_events_code__.js')) { return STATE_EVENTS_SOURCE; } if (options.trackState === 'summary' && (id === '/__ct_state_summary__' || id === '/__ct_state_summary__.js')) { const cfg = { allowKeys: options.allowKeys, maxAttrLength: options.maxAttrLength, throttleMs: options.throttleMs, attrName: `${options.attributePrefix}-state`, showFullObjects: options.showFullObjects }; return `import { setupStateSummary } from '/__ct_state_summary_code__.js';\nsetupStateSummary(${JSON.stringify(cfg)});`; } if (options.trackState === 'summary' && (id === '/__ct_state_summary_code__' || id === '/__ct_state_summary_code__.js')) { return STATE_SUMMARY_SOURCE; } return null; }, // Use Vite's HTML transform to inject runtime early transformIndexHtml(html) { if (options.trackState === 'off') return null; if (options.trackState === 'events') { return html.replace(/<head>/i, `<head>\n <script type=\"module\">import '/__ct_state_events__.js';</script>`); } if (options.trackState === 'summary') { return html.replace(/<head>/i, `<head>\n <script type=\"module\">import '/__ct_state_summary__.js';</script>`); } return null; }, async transform(code, id) { try { // Skip virtual modules - they're served as-is by the load hook if (id.startsWith('/__ct_state_events') || id.startsWith('/__ct_state_summary')) { return null; } // Inject dev-only state events runtime as a virtual module import in index.html if (options.trackState !== 'off' && /index\.html$/.test(id)) { log('Injecting state-events runtime'); const injected = code.replace(/<head>/i, `<head>\n <script type=\"module\">import '/__ct_state_events__.js';</script>`); return { code: injected, map: null }; } // Check if file should be processed (strip Vite query/hash) const cleanId = id.split('?')[0].split('#')[0]; if (!validExtensions.has(path.extname(cleanId))) { return null; } // Check exclude patterns if (options.excludePatterns.some(pattern => cleanId.includes(pattern))) { return null; } log(`Processing file: ${id}`); // Parse the code into an AST const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], }); const ms = new MagicString(code); const fileRelative = path.relative(process.cwd(), id); let transformCount = 0; // Walk through the AST looking for JSX elements const jsxNodes = []; estreeWalker.walk(ast, { enter(node) { if (node.type !== "JSXOpeningElement") return; if (node.name?.type !== "JSXIdentifier") return; const tagName = node.name.name; if (!tagName) return; // Check if already tagged const alreadyTagged = node.attributes?.some((attr) => attr.type === "JSXAttribute" && attr.name?.name?.startsWith(options.attributePrefix)); if (!alreadyTagged) { jsxNodes.push(node); } } }); // Process JSX nodes asynchronously for (const node of jsxNodes) { try { const tagName = node.name.name; // Get location info const loc = node.loc?.start; if (!loc) continue; // Calculate accurate line number using original source file analysis const correctedLine = await calculateAccurateLineNumber(id, loc.line, node); const correctedLoc = { line: correctedLine, column: loc.column }; // Generate ID using custom generator or default const componentId = options.customIdGenerator(fileRelative, correctedLoc); // Build attributes string let attributesString = ` ${options.attributePrefix}-id="${componentId}"`; if (options.includeTagName) { attributesString += ` ${options.attributePrefix}-name="${tagName}"`; } // Insert attributes after the tag name if (node.name.end != null) { ms.appendLeft(node.name.end, attributesString); transformCount++; } } catch (error) { console.warn(`[component-instrumentation] Warning: Failed to process JSX node in ${id}:`, error); } } // Return transformed code if changes were made if (transformCount > 0) { log(`Transformed ${transformCount} components in ${fileRelative}`); return { code: ms.toString(), map: ms.generateMap({ hires: true, source: id }), }; } return null; } catch (error) { console.warn(`[component-instrumentation] Warning: Failed to transform ${id}:`, error); return null; } } }; } exports.buildSummaryDev = buildSummaryDev; exports.default = componentInstrumentation; //# sourceMappingURL=index.js.map