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
JavaScript
'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