trickle-observe 0.2.68 → 0.2.70

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.
@@ -55,7 +55,8 @@ async function transform({ src, filename, options }) {
55
55
  const isJsFile = ext === '.ts' || ext === '.js';
56
56
  if ((isReactFile || isJsFile) && !filename.includes('node_modules') && !filename.includes('trickle-observe')) {
57
57
  const moduleName = path_1.default.basename(filename).replace(/\.[jt]sx?$/, '');
58
- const transformed = (0, vite_plugin_1.transformEsmSource)(src, filename, moduleName, backendUrl, debug, false);
58
+ // React Native has a JS runtime with fs access, use SSR mode
59
+ const transformed = (0, vite_plugin_1.transformEsmSource)(src, filename, moduleName, backendUrl, debug, false, null, true);
59
60
  if (transformed !== src) {
60
61
  if (debug) {
61
62
  console.log(`[trickle/metro] Instrumented ${filename}`);
@@ -37,7 +37,8 @@ function trickleNextLoader(source) {
37
37
  const traceVars = options.traceVars ?? true;
38
38
  const moduleName = path_1.default.basename(resourcePath).replace(/\.[jt]sx?$/, '');
39
39
  try {
40
- const transformed = (0, vite_plugin_1.transformEsmSource)(source, resourcePath, moduleName, backendUrl, debug, traceVars, source);
40
+ // Next.js SSR renders all components on the server, so use SSR mode (node:fs)
41
+ const transformed = (0, vite_plugin_1.transformEsmSource)(source, resourcePath, moduleName, backendUrl, debug, traceVars, source, true);
41
42
  if (debug && transformed !== source) {
42
43
  console.log(`[trickle/next] Instrumented ${resourcePath}`);
43
44
  }
@@ -32,10 +32,13 @@ export interface TricklePluginOptions {
32
32
  export declare function tricklePlugin(options?: TricklePluginOptions): {
33
33
  name: string;
34
34
  enforce: "post";
35
- transform(code: string, id: string): {
35
+ configureServer(server: any): void;
36
+ transform(code: string, id: string, options?: {
37
+ ssr?: boolean;
38
+ }): {
36
39
  code: string;
37
40
  map: null;
38
41
  } | null;
39
42
  };
40
- export declare function transformEsmSource(source: string, filename: string, moduleName: string, backendUrl: string, debug: boolean, traceVars: boolean, originalSource?: string | null): string;
43
+ export declare function transformEsmSource(source: string, filename: string, moduleName: string, backendUrl: string, debug: boolean, traceVars: boolean, originalSource?: string | null, isSSR?: boolean): string;
41
44
  export default tricklePlugin;
@@ -67,9 +67,33 @@ function tricklePlugin(options = {}) {
67
67
  return {
68
68
  name: 'trickle-observe',
69
69
  enforce: 'post',
70
- transform(code, id) {
70
+ configureServer(server) {
71
+ // Listen for variable data from browser clients via Vite's HMR WebSocket
72
+ const hot = server.hot || server.ws;
73
+ if (hot && hot.on) {
74
+ const varDir = process.env.TRICKLE_LOCAL_DIR || path_1.default.join(process.cwd(), '.trickle');
75
+ try {
76
+ fs_1.default.mkdirSync(varDir, { recursive: true });
77
+ }
78
+ catch { }
79
+ const varsFile = path_1.default.join(varDir, 'variables.jsonl');
80
+ hot.on('trickle:vars', (data, client) => {
81
+ try {
82
+ if (data && data.lines) {
83
+ fs_1.default.appendFileSync(varsFile, data.lines);
84
+ }
85
+ }
86
+ catch { }
87
+ });
88
+ if (debug) {
89
+ console.log(`[trickle/vite] WebSocket bridge active → ${varsFile}`);
90
+ }
91
+ }
92
+ },
93
+ transform(code, id, options) {
71
94
  if (!shouldTransform(id))
72
95
  return null;
96
+ const isSSR = options?.ssr === true;
73
97
  // Read the original source file to get accurate line numbers.
74
98
  // Vite transforms the code before our plugin (enforce: 'post'),
75
99
  // so line numbers from `code` don't match the original .ts file.
@@ -81,11 +105,11 @@ function tricklePlugin(options = {}) {
81
105
  // If we can't read the original, we'll use transformed line numbers
82
106
  }
83
107
  const moduleName = path_1.default.basename(id).replace(/\.[jt]sx?$/, '');
84
- const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource);
108
+ const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource, isSSR);
85
109
  if (transformed === code)
86
110
  return null;
87
111
  if (debug) {
88
- console.log(`[trickle/vite] Transformed ${moduleName} (${id})`);
112
+ console.log(`[trickle/vite] Transformed ${moduleName} (${id}) [${isSSR ? 'SSR' : 'browser'}]`);
89
113
  }
90
114
  return { code: transformed, map: null };
91
115
  },
@@ -456,6 +480,230 @@ function extractDestructuredNames(pattern) {
456
480
  }
457
481
  return names;
458
482
  }
483
+ /**
484
+ * Find for-loop variable declarations and return insertions for tracing.
485
+ * Handles:
486
+ * for (const item of items) { ... } → trace item
487
+ * for (const [key, val] of entries) { ... } → trace key, val
488
+ * for (const { a, b } of items) { ... } → trace a, b
489
+ * for (const key in obj) { ... } → trace key
490
+ * for (let i = 0; i < n; i++) { ... } → trace i
491
+ * Inserts trace calls at the start of the loop body.
492
+ */
493
+ function findForLoopVars(source) {
494
+ const results = [];
495
+ // Match: for (const/let/var ...
496
+ const forRegex = /\bfor\s*\(/g;
497
+ let match;
498
+ while ((match = forRegex.exec(source)) !== null) {
499
+ const afterParen = match.index + match[0].length;
500
+ // Skip whitespace
501
+ let pos = afterParen;
502
+ while (pos < source.length && /\s/.test(source[pos]))
503
+ pos++;
504
+ // Expect const/let/var
505
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
506
+ if (!declMatch)
507
+ continue;
508
+ pos += declMatch[0].length;
509
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
510
+ const varNames = [];
511
+ const patternStart = pos;
512
+ if (source[pos] === '{' || source[pos] === '[') {
513
+ // Destructured: find matching brace/bracket
514
+ const open = source[pos];
515
+ const close = open === '{' ? '}' : ']';
516
+ let depth = 1;
517
+ let end = pos + 1;
518
+ while (end < source.length && depth > 0) {
519
+ if (source[end] === open)
520
+ depth++;
521
+ else if (source[end] === close)
522
+ depth--;
523
+ end++;
524
+ }
525
+ const pattern = source.slice(pos, end);
526
+ const names = extractDestructuredNames(pattern);
527
+ varNames.push(...names);
528
+ pos = end;
529
+ }
530
+ else {
531
+ // Simple identifier
532
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
533
+ if (!idMatch)
534
+ continue;
535
+ varNames.push(idMatch[1]);
536
+ pos += idMatch[0].length;
537
+ }
538
+ if (varNames.length === 0)
539
+ continue;
540
+ // Skip trickle internals
541
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c'))
542
+ continue;
543
+ // Now find the opening `{` of the loop body
544
+ // Skip everything until the `)` that closes the for(...)
545
+ let parenDepth = 1; // We're inside the for(
546
+ while (pos < source.length && parenDepth > 0) {
547
+ const ch = source[pos];
548
+ if (ch === '(')
549
+ parenDepth++;
550
+ else if (ch === ')')
551
+ parenDepth--;
552
+ else if (ch === '"' || ch === "'" || ch === '`') {
553
+ const q = ch;
554
+ pos++;
555
+ while (pos < source.length && source[pos] !== q) {
556
+ if (source[pos] === '\\')
557
+ pos++;
558
+ pos++;
559
+ }
560
+ }
561
+ pos++;
562
+ }
563
+ // Now find the `{` after the closing `)`
564
+ while (pos < source.length && /\s/.test(source[pos]))
565
+ pos++;
566
+ if (pos >= source.length || source[pos] !== '{')
567
+ continue;
568
+ const bodyBrace = pos;
569
+ // Calculate line number
570
+ let lineNo = 1;
571
+ for (let i = 0; i < match.index; i++) {
572
+ if (source[i] === '\n')
573
+ lineNo++;
574
+ }
575
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
576
+ }
577
+ return results;
578
+ }
579
+ /**
580
+ * Find function parameter names and return insertions for tracing at the start
581
+ * of function bodies. Traces the runtime values of all parameters.
582
+ * Handles: function declarations, arrow functions, method definitions.
583
+ * Skips: React components (already tracked via __trickle_rc with props).
584
+ */
585
+ function findFunctionParams(source, isReactFile) {
586
+ const results = [];
587
+ // Match function declarations: function name(params) {
588
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
589
+ let match;
590
+ while ((match = funcDeclRegex.exec(source)) !== null) {
591
+ const name = match[1];
592
+ if (name === 'require' || name === 'exports' || name === 'module')
593
+ continue;
594
+ if (name.startsWith('__trickle'))
595
+ continue;
596
+ // Skip React components (uppercase) in React files — already tracked
597
+ if (isReactFile && /^[A-Z]/.test(name))
598
+ continue;
599
+ const afterParen = match.index + match[0].length;
600
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
601
+ if (bodyBrace === -1)
602
+ continue;
603
+ // Extract parameter names from between ( and )
604
+ const paramStr = source.slice(afterParen, bodyBrace);
605
+ const closeParen = paramStr.indexOf(')');
606
+ if (closeParen === -1)
607
+ continue;
608
+ const rawParams = paramStr.slice(0, closeParen).trim();
609
+ const paramNames = extractParamNames(rawParams);
610
+ if (paramNames.length === 0)
611
+ continue;
612
+ let lineNo = 1;
613
+ for (let i = 0; i < match.index; i++) {
614
+ if (source[i] === '\n')
615
+ lineNo++;
616
+ }
617
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
618
+ }
619
+ // Match arrow functions: const name = (params) => {
620
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
621
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
622
+ const name = match[1];
623
+ if (name.startsWith('__trickle'))
624
+ continue;
625
+ // Skip React components in React files
626
+ if (isReactFile && /^[A-Z]/.test(name))
627
+ continue;
628
+ const rawParams = match[2].trim();
629
+ const paramNames = extractParamNames(rawParams);
630
+ if (paramNames.length === 0)
631
+ continue;
632
+ // Find the { position
633
+ const bracePos = match.index + match[0].length - 1;
634
+ let lineNo = 1;
635
+ for (let i = 0; i < match.index; i++) {
636
+ if (source[i] === '\n')
637
+ lineNo++;
638
+ }
639
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
640
+ }
641
+ return results;
642
+ }
643
+ /**
644
+ * Extract parameter names from a function parameter string.
645
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
646
+ * Skips type annotations.
647
+ */
648
+ function extractParamNames(rawParams) {
649
+ if (!rawParams)
650
+ return [];
651
+ const names = [];
652
+ // Split by commas at depth 0
653
+ const parts = [];
654
+ let depth = 0;
655
+ let current = '';
656
+ for (const ch of rawParams) {
657
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<')
658
+ depth++;
659
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>')
660
+ depth--;
661
+ else if (ch === ',' && depth === 0) {
662
+ parts.push(current.trim());
663
+ current = '';
664
+ continue;
665
+ }
666
+ current += ch;
667
+ }
668
+ if (current.trim())
669
+ parts.push(current.trim());
670
+ for (const part of parts) {
671
+ const trimmed = part.trim();
672
+ if (!trimmed)
673
+ continue;
674
+ // Destructured params — extract individual names
675
+ if (trimmed.startsWith('{')) {
676
+ const closeBrace = trimmed.indexOf('}');
677
+ if (closeBrace !== -1) {
678
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
679
+ names.push(...destructNames);
680
+ }
681
+ continue;
682
+ }
683
+ if (trimmed.startsWith('[')) {
684
+ const closeBracket = trimmed.indexOf(']');
685
+ if (closeBracket !== -1) {
686
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
687
+ names.push(...destructNames);
688
+ }
689
+ continue;
690
+ }
691
+ // Rest parameter: ...args
692
+ if (trimmed.startsWith('...')) {
693
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
694
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
695
+ names.push(restName);
696
+ }
697
+ continue;
698
+ }
699
+ // Simple param: name or name: Type or name = default
700
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
701
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
702
+ names.push(paramName);
703
+ }
704
+ }
705
+ return names;
706
+ }
459
707
  /**
460
708
  * Transform ESM source code to wrap function declarations and trace variables.
461
709
  *
@@ -520,7 +768,7 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
520
768
  function escapeRegexStr(str) {
521
769
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
522
770
  }
523
- function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
771
+ function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
524
772
  // Detect React files for component render tracking
525
773
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
526
774
  // Match top-level and nested function declarations (including async, export, export default)
@@ -863,7 +1111,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
863
1111
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
864
1112
  // Find destructured variable declarations for tracing
865
1113
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
866
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1114
+ // Find for-loop variable declarations for tracing
1115
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1116
+ // Find function parameter names for tracing
1117
+ const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1118
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
867
1119
  return source;
868
1120
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
869
1121
  // Map transformed line numbers to original source line numbers.
@@ -883,12 +1135,55 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
883
1135
  di.lineNo = origLine;
884
1136
  }
885
1137
  }
1138
+ // Fix for-loop var line numbers
1139
+ for (const fi of forLoopInsertions) {
1140
+ if (fi.varNames.length > 0) {
1141
+ // Search for 'for' keyword near the expected line
1142
+ const pattern = /\bfor\s*\(/;
1143
+ for (let delta = 0; delta <= 80; delta++) {
1144
+ const fwd = fi.lineNo - 1 + delta;
1145
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1146
+ fi.lineNo = fwd + 1;
1147
+ break;
1148
+ }
1149
+ if (delta > 0 && delta <= 10) {
1150
+ const bwd = fi.lineNo - 1 - delta;
1151
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1152
+ fi.lineNo = bwd + 1;
1153
+ break;
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ // Fix function param line numbers
1160
+ for (const fp of funcParamInsertions) {
1161
+ if (fp.paramNames.length > 0) {
1162
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1163
+ for (let delta = 0; delta <= 80; delta++) {
1164
+ const fwd = fp.lineNo - 1 + delta;
1165
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1166
+ fp.lineNo = fwd + 1;
1167
+ break;
1168
+ }
1169
+ if (delta > 0 && delta <= 10) {
1170
+ const bwd = fp.lineNo - 1 - delta;
1171
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1172
+ fp.lineNo = bwd + 1;
1173
+ break;
1174
+ }
1175
+ }
1176
+ }
1177
+ }
1178
+ }
886
1179
  }
887
1180
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1181
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
888
1182
  const importLines = [
889
1183
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
890
1184
  ];
891
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
1185
+ if (needsTracing && isSSR) {
1186
+ // SSR/Node.js — use file system for writing
892
1187
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
893
1188
  }
894
1189
  const prefixLines = [
@@ -909,23 +1204,32 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
909
1204
  ` return __trickle_wrapFn(fn, opts);`,
910
1205
  `}`,
911
1206
  ];
1207
+ // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
1208
+ if (needsTracing) {
1209
+ if (isSSR) {
1210
+ // SSR/Node.js mode — write directly to file system
1211
+ prefixLines.push(`let __trickle_varsFile = null;`, `function __trickle_send(line) {`, ` try {`, ` if (!__trickle_varsFile) {`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` __trickle_varsFile = __trickle_join(dir, 'variables.jsonl');`, ` }`, ` __trickle_appendFileSync(__trickle_varsFile, line + '\\n');`, ` } catch(e) {}`, `}`);
1212
+ }
1213
+ else {
1214
+ // Browser mode — buffer and send via Vite HMR WebSocket
1215
+ prefixLines.push(`const __trickle_sendBuf = [];`, `let __trickle_sendTimer = null;`, `function __trickle_flush() {`, ` if (__trickle_sendBuf.length === 0) return;`, ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`, ` __trickle_sendBuf.length = 0;`, ` try { if (import.meta.hot) import.meta.hot.send('trickle:vars', { lines }); } catch(e) {}`, `}`, `function __trickle_send(line) {`, ` __trickle_sendBuf.push(line);`, ` if (!__trickle_sendTimer) {`, ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`, ` }`, `}`);
1216
+ }
1217
+ }
912
1218
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
913
- // Uses synchronous writes (appendFileSync) to guarantee data persists even if Vitest
914
- // kills the worker abruptly without firing exit events.
915
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
916
- prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Set();`, ` let _varsFile = null;`, ` function _inferType(v, d) {`, ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`, ` if (v === null) return { kind: 'primitive', name: 'null' };`, ` if (v === undefined) return { kind: 'primitive', name: 'undefined' };`, ` const t = typeof v;`, ` if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint' || t === 'symbol') return { kind: 'primitive', name: t };`, ` if (t === 'function') return { kind: 'function' };`, ` if (Array.isArray(v)) { return v.length === 0 ? { kind: 'array', element: { kind: 'primitive', name: 'unknown' } } : { kind: 'array', element: _inferType(v[0], d-1) }; }`, ` if (t === 'object') {`, ` if (v instanceof Date) return { kind: 'object', properties: { __date: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof RegExp) return { kind: 'object', properties: { __regexp: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Error) return { kind: 'object', properties: { __error: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Promise) return { kind: 'promise', resolved: { kind: 'primitive', name: 'unknown' } };`, ` const props = {}; const keys = Object.keys(v).slice(0, 20);`, ` for (const k of keys) { try { props[k] = _inferType(v[k], d-1); } catch(e) { props[k] = { kind: 'primitive', name: 'unknown' }; } }`, ` return { kind: 'object', properties: props };`, ` }`, ` return { kind: 'primitive', name: 'unknown' };`, ` }`, ` function _sanitize(v, d) {`, ` if (d <= 0) return '[truncated]'; if (v === null || v === undefined) return v; const t = typeof v;`, ` if (t === 'string') return v.length > 100 ? v.substring(0, 100) + '...' : v;`, ` if (t === 'number' || t === 'boolean') return v; if (t === 'bigint') return String(v);`, ` if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';`, ` if (Array.isArray(v)) return v.slice(0, 3).map(i => _sanitize(i, d-1));`, ` if (t === 'object') { if (v instanceof Date) return v.toISOString(); if (v instanceof RegExp) return String(v); if (v instanceof Error) return { error: v.message }; if (v instanceof Promise) return '[Promise]';`, ` const r = {}; const keys = Object.keys(v).slice(0, 10); for (const k of keys) { try { r[k] = _sanitize(v[k], d-1); } catch(e) { r[k] = '[unreadable]'; } } return r; }`, ` return String(v);`, ` }`, ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`, ` try {`, ` if (!_varsFile) {`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` _varsFile = __trickle_join(dir, 'variables.jsonl');`, ` }`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const ck = file + ':' + l + ':' + n + ':' + th;`, ` if (_cache.has(ck)) return;`, ` _cache.add(ck);`, ` __trickle_appendFileSync(_varsFile, JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }) + '\\n');`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1219
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1220
+ prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Set();`, ` function _inferType(v, d) {`, ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`, ` if (v === null) return { kind: 'primitive', name: 'null' };`, ` if (v === undefined) return { kind: 'primitive', name: 'undefined' };`, ` const t = typeof v;`, ` if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint' || t === 'symbol') return { kind: 'primitive', name: t };`, ` if (t === 'function') return { kind: 'function' };`, ` if (Array.isArray(v)) { return v.length === 0 ? { kind: 'array', element: { kind: 'primitive', name: 'unknown' } } : { kind: 'array', element: _inferType(v[0], d-1) }; }`, ` if (t === 'object') {`, ` if (v instanceof Date) return { kind: 'object', properties: { __date: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof RegExp) return { kind: 'object', properties: { __regexp: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Error) return { kind: 'object', properties: { __error: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Promise) return { kind: 'promise', resolved: { kind: 'primitive', name: 'unknown' } };`, ` const props = {}; const keys = Object.keys(v).slice(0, 20);`, ` for (const k of keys) { try { props[k] = _inferType(v[k], d-1); } catch(e) { props[k] = { kind: 'primitive', name: 'unknown' }; } }`, ` return { kind: 'object', properties: props };`, ` }`, ` return { kind: 'primitive', name: 'unknown' };`, ` }`, ` function _sanitize(v, d) {`, ` if (d <= 0) return '[truncated]'; if (v === null || v === undefined) return v; const t = typeof v;`, ` if (t === 'string') return v.length > 100 ? v.substring(0, 100) + '...' : v;`, ` if (t === 'number' || t === 'boolean') return v; if (t === 'bigint') return String(v);`, ` if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';`, ` if (Array.isArray(v)) return v.slice(0, 3).map(i => _sanitize(i, d-1));`, ` if (t === 'object') { if (v instanceof Date) return v.toISOString(); if (v instanceof RegExp) return String(v); if (v instanceof Error) return { error: v.message }; if (v instanceof Promise) return '[Promise]';`, ` const r = {}; const keys = Object.keys(v).slice(0, 10); for (const k of keys) { try { r[k] = _sanitize(v[k], d-1); } catch(e) { r[k] = '[unreadable]'; } } return r; }`, ` return String(v);`, ` }`, ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`, ` try {`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const ck = file + ':' + l + ':' + n + ':' + th;`, ` if (_cache.has(ck)) return;`, ` _cache.add(ck);`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
917
1221
  }
918
1222
  // Add React component render tracker if needed
919
1223
  if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
920
- prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` // Detect which props changed vs previous render`, ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`, ` if (prevProps && count > 1) {`, ` const changedProps = [];`, ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`, ` for (const k of allKeys) {`, ` const prev = prevProps[k];`, ` const curr = propSample[k];`, ` if (String(prev) !== String(curr)) {`, ` changedProps.push({ key: k, from: prev, to: curr });`, ` }`, ` }`, ` if (changedProps.length > 0) rec.changedProps = changedProps;`, ` }`, ` globalThis.__trickle_react_prev_props.set(key, propSample);`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
1224
+ prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`, ` if (prevProps && count > 1) {`, ` const changedProps = [];`, ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`, ` for (const k of allKeys) {`, ` const prev = prevProps[k];`, ` const curr = propSample[k];`, ` if (String(prev) !== String(curr)) {`, ` changedProps.push({ key: k, from: prev, to: curr });`, ` }`, ` }`, ` if (changedProps.length > 0) rec.changedProps = changedProps;`, ` }`, ` globalThis.__trickle_react_prev_props.set(key, propSample);`, ` } catch(e2) {}`, ` }`, ` __trickle_send(JSON.stringify(rec));`, ` } catch(e) {}`, `}`);
921
1225
  }
922
1226
  // Add React hook tracker if needed
923
1227
  if (hookInsertions.length > 0) {
924
- prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
1228
+ prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` __trickle_send(JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }));`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
925
1229
  }
926
1230
  // Add useState setter tracker if needed
927
1231
  if (stateInsertions.length > 0) {
928
- prefixLines.push(`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`, `function __trickle_ss(stateName, line, origSetter) {`, ` return function(newVal) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`, ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_state_counts.set(key, n);`, ` const t = typeof newVal;`, ` let sample;`, ` if (t === 'function') sample = '[fn updater]';`, ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`, ` else if (t === 'number' || t === 'boolean') sample = newVal;`, ` else if (newVal === null || newVal === undefined) sample = newVal;`, ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`, ` else sample = '[object]';`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`, ` } catch(e) {}`, ` return origSetter(newVal);`, ` };`, `}`);
1232
+ prefixLines.push(`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`, `function __trickle_ss(stateName, line, origSetter) {`, ` return function(newVal) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`, ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_state_counts.set(key, n);`, ` const t = typeof newVal;`, ` let sample;`, ` if (t === 'function') sample = '[fn updater]';`, ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`, ` else if (t === 'number' || t === 'boolean') sample = newVal;`, ` else if (newVal === null || newVal === undefined) sample = newVal;`, ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`, ` else sample = '[object]';`, ` __trickle_send(JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }));`, ` } catch(e) {}`, ` return origSetter(newVal);`, ` };`, `}`);
929
1233
  }
930
1234
  prefixLines.push('');
931
1235
  const prefix = prefixLines.join('\n');
@@ -950,6 +1254,22 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
950
1254
  code: `\n;try{${calls}}catch(__e){}\n`,
951
1255
  });
952
1256
  }
1257
+ // For-loop variable insertions: insert trace at start of loop body
1258
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1259
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1260
+ allInsertions.push({
1261
+ position: bodyStart,
1262
+ code: `\ntry{${calls}}catch(__e){}\n`,
1263
+ });
1264
+ }
1265
+ // Function parameter insertions: insert trace at start of function body
1266
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1267
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1268
+ allInsertions.push({
1269
+ position: bodyStart,
1270
+ code: `\ntry{${calls}}catch(__e){}\n`,
1271
+ });
1272
+ }
953
1273
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
954
1274
  allInsertions.push({
955
1275
  position,