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.
@@ -60,9 +60,33 @@ export function tricklePlugin(options = {}) {
60
60
  return {
61
61
  name: 'trickle-observe',
62
62
  enforce: 'post',
63
- transform(code, id) {
63
+ configureServer(server) {
64
+ // Listen for variable data from browser clients via Vite's HMR WebSocket
65
+ const hot = server.hot || server.ws;
66
+ if (hot && hot.on) {
67
+ const varDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
68
+ try {
69
+ fs.mkdirSync(varDir, { recursive: true });
70
+ }
71
+ catch { }
72
+ const varsFile = path.join(varDir, 'variables.jsonl');
73
+ hot.on('trickle:vars', (data, client) => {
74
+ try {
75
+ if (data && data.lines) {
76
+ fs.appendFileSync(varsFile, data.lines);
77
+ }
78
+ }
79
+ catch { }
80
+ });
81
+ if (debug) {
82
+ console.log(`[trickle/vite] WebSocket bridge active → ${varsFile}`);
83
+ }
84
+ }
85
+ },
86
+ transform(code, id, options) {
64
87
  if (!shouldTransform(id))
65
88
  return null;
89
+ const isSSR = options?.ssr === true;
66
90
  // Read the original source file to get accurate line numbers.
67
91
  // Vite transforms the code before our plugin (enforce: 'post'),
68
92
  // so line numbers from `code` don't match the original .ts file.
@@ -74,11 +98,11 @@ export function tricklePlugin(options = {}) {
74
98
  // If we can't read the original, we'll use transformed line numbers
75
99
  }
76
100
  const moduleName = path.basename(id).replace(/\.[jt]sx?$/, '');
77
- const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource);
101
+ const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource, isSSR);
78
102
  if (transformed === code)
79
103
  return null;
80
104
  if (debug) {
81
- console.log(`[trickle/vite] Transformed ${moduleName} (${id})`);
105
+ console.log(`[trickle/vite] Transformed ${moduleName} (${id}) [${isSSR ? 'SSR' : 'browser'}]`);
82
106
  }
83
107
  return { code: transformed, map: null };
84
108
  },
@@ -449,6 +473,230 @@ function extractDestructuredNames(pattern) {
449
473
  }
450
474
  return names;
451
475
  }
476
+ /**
477
+ * Find for-loop variable declarations and return insertions for tracing.
478
+ * Handles:
479
+ * for (const item of items) { ... } → trace item
480
+ * for (const [key, val] of entries) { ... } → trace key, val
481
+ * for (const { a, b } of items) { ... } → trace a, b
482
+ * for (const key in obj) { ... } → trace key
483
+ * for (let i = 0; i < n; i++) { ... } → trace i
484
+ * Inserts trace calls at the start of the loop body.
485
+ */
486
+ function findForLoopVars(source) {
487
+ const results = [];
488
+ // Match: for (const/let/var ...
489
+ const forRegex = /\bfor\s*\(/g;
490
+ let match;
491
+ while ((match = forRegex.exec(source)) !== null) {
492
+ const afterParen = match.index + match[0].length;
493
+ // Skip whitespace
494
+ let pos = afterParen;
495
+ while (pos < source.length && /\s/.test(source[pos]))
496
+ pos++;
497
+ // Expect const/let/var
498
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
499
+ if (!declMatch)
500
+ continue;
501
+ pos += declMatch[0].length;
502
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
503
+ const varNames = [];
504
+ const patternStart = pos;
505
+ if (source[pos] === '{' || source[pos] === '[') {
506
+ // Destructured: find matching brace/bracket
507
+ const open = source[pos];
508
+ const close = open === '{' ? '}' : ']';
509
+ let depth = 1;
510
+ let end = pos + 1;
511
+ while (end < source.length && depth > 0) {
512
+ if (source[end] === open)
513
+ depth++;
514
+ else if (source[end] === close)
515
+ depth--;
516
+ end++;
517
+ }
518
+ const pattern = source.slice(pos, end);
519
+ const names = extractDestructuredNames(pattern);
520
+ varNames.push(...names);
521
+ pos = end;
522
+ }
523
+ else {
524
+ // Simple identifier
525
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
526
+ if (!idMatch)
527
+ continue;
528
+ varNames.push(idMatch[1]);
529
+ pos += idMatch[0].length;
530
+ }
531
+ if (varNames.length === 0)
532
+ continue;
533
+ // Skip trickle internals
534
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c'))
535
+ continue;
536
+ // Now find the opening `{` of the loop body
537
+ // Skip everything until the `)` that closes the for(...)
538
+ let parenDepth = 1; // We're inside the for(
539
+ while (pos < source.length && parenDepth > 0) {
540
+ const ch = source[pos];
541
+ if (ch === '(')
542
+ parenDepth++;
543
+ else if (ch === ')')
544
+ parenDepth--;
545
+ else if (ch === '"' || ch === "'" || ch === '`') {
546
+ const q = ch;
547
+ pos++;
548
+ while (pos < source.length && source[pos] !== q) {
549
+ if (source[pos] === '\\')
550
+ pos++;
551
+ pos++;
552
+ }
553
+ }
554
+ pos++;
555
+ }
556
+ // Now find the `{` after the closing `)`
557
+ while (pos < source.length && /\s/.test(source[pos]))
558
+ pos++;
559
+ if (pos >= source.length || source[pos] !== '{')
560
+ continue;
561
+ const bodyBrace = pos;
562
+ // Calculate line number
563
+ let lineNo = 1;
564
+ for (let i = 0; i < match.index; i++) {
565
+ if (source[i] === '\n')
566
+ lineNo++;
567
+ }
568
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
569
+ }
570
+ return results;
571
+ }
572
+ /**
573
+ * Find function parameter names and return insertions for tracing at the start
574
+ * of function bodies. Traces the runtime values of all parameters.
575
+ * Handles: function declarations, arrow functions, method definitions.
576
+ * Skips: React components (already tracked via __trickle_rc with props).
577
+ */
578
+ function findFunctionParams(source, isReactFile) {
579
+ const results = [];
580
+ // Match function declarations: function name(params) {
581
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
582
+ let match;
583
+ while ((match = funcDeclRegex.exec(source)) !== null) {
584
+ const name = match[1];
585
+ if (name === 'require' || name === 'exports' || name === 'module')
586
+ continue;
587
+ if (name.startsWith('__trickle'))
588
+ continue;
589
+ // Skip React components (uppercase) in React files — already tracked
590
+ if (isReactFile && /^[A-Z]/.test(name))
591
+ continue;
592
+ const afterParen = match.index + match[0].length;
593
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
594
+ if (bodyBrace === -1)
595
+ continue;
596
+ // Extract parameter names from between ( and )
597
+ const paramStr = source.slice(afterParen, bodyBrace);
598
+ const closeParen = paramStr.indexOf(')');
599
+ if (closeParen === -1)
600
+ continue;
601
+ const rawParams = paramStr.slice(0, closeParen).trim();
602
+ const paramNames = extractParamNames(rawParams);
603
+ if (paramNames.length === 0)
604
+ continue;
605
+ let lineNo = 1;
606
+ for (let i = 0; i < match.index; i++) {
607
+ if (source[i] === '\n')
608
+ lineNo++;
609
+ }
610
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
611
+ }
612
+ // Match arrow functions: const name = (params) => {
613
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
614
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
615
+ const name = match[1];
616
+ if (name.startsWith('__trickle'))
617
+ continue;
618
+ // Skip React components in React files
619
+ if (isReactFile && /^[A-Z]/.test(name))
620
+ continue;
621
+ const rawParams = match[2].trim();
622
+ const paramNames = extractParamNames(rawParams);
623
+ if (paramNames.length === 0)
624
+ continue;
625
+ // Find the { position
626
+ const bracePos = match.index + match[0].length - 1;
627
+ let lineNo = 1;
628
+ for (let i = 0; i < match.index; i++) {
629
+ if (source[i] === '\n')
630
+ lineNo++;
631
+ }
632
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
633
+ }
634
+ return results;
635
+ }
636
+ /**
637
+ * Extract parameter names from a function parameter string.
638
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
639
+ * Skips type annotations.
640
+ */
641
+ function extractParamNames(rawParams) {
642
+ if (!rawParams)
643
+ return [];
644
+ const names = [];
645
+ // Split by commas at depth 0
646
+ const parts = [];
647
+ let depth = 0;
648
+ let current = '';
649
+ for (const ch of rawParams) {
650
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<')
651
+ depth++;
652
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>')
653
+ depth--;
654
+ else if (ch === ',' && depth === 0) {
655
+ parts.push(current.trim());
656
+ current = '';
657
+ continue;
658
+ }
659
+ current += ch;
660
+ }
661
+ if (current.trim())
662
+ parts.push(current.trim());
663
+ for (const part of parts) {
664
+ const trimmed = part.trim();
665
+ if (!trimmed)
666
+ continue;
667
+ // Destructured params — extract individual names
668
+ if (trimmed.startsWith('{')) {
669
+ const closeBrace = trimmed.indexOf('}');
670
+ if (closeBrace !== -1) {
671
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
672
+ names.push(...destructNames);
673
+ }
674
+ continue;
675
+ }
676
+ if (trimmed.startsWith('[')) {
677
+ const closeBracket = trimmed.indexOf(']');
678
+ if (closeBracket !== -1) {
679
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
680
+ names.push(...destructNames);
681
+ }
682
+ continue;
683
+ }
684
+ // Rest parameter: ...args
685
+ if (trimmed.startsWith('...')) {
686
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
687
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
688
+ names.push(restName);
689
+ }
690
+ continue;
691
+ }
692
+ // Simple param: name or name: Type or name = default
693
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
694
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
695
+ names.push(paramName);
696
+ }
697
+ }
698
+ return names;
699
+ }
452
700
  /**
453
701
  * Transform ESM source code to wrap function declarations and trace variables.
454
702
  *
@@ -513,7 +761,7 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
513
761
  function escapeRegexStr(str) {
514
762
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
515
763
  }
516
- export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
764
+ export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
517
765
  // Detect React files for component render tracking
518
766
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
519
767
  // Match top-level and nested function declarations (including async, export, export default)
@@ -856,7 +1104,11 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
856
1104
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
857
1105
  // Find destructured variable declarations for tracing
858
1106
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
859
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1107
+ // Find for-loop variable declarations for tracing
1108
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1109
+ // Find function parameter names for tracing
1110
+ const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1111
+ 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)
860
1112
  return source;
861
1113
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
862
1114
  // Map transformed line numbers to original source line numbers.
@@ -876,12 +1128,55 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
876
1128
  di.lineNo = origLine;
877
1129
  }
878
1130
  }
1131
+ // Fix for-loop var line numbers
1132
+ for (const fi of forLoopInsertions) {
1133
+ if (fi.varNames.length > 0) {
1134
+ // Search for 'for' keyword near the expected line
1135
+ const pattern = /\bfor\s*\(/;
1136
+ for (let delta = 0; delta <= 80; delta++) {
1137
+ const fwd = fi.lineNo - 1 + delta;
1138
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1139
+ fi.lineNo = fwd + 1;
1140
+ break;
1141
+ }
1142
+ if (delta > 0 && delta <= 10) {
1143
+ const bwd = fi.lineNo - 1 - delta;
1144
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1145
+ fi.lineNo = bwd + 1;
1146
+ break;
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ // Fix function param line numbers
1153
+ for (const fp of funcParamInsertions) {
1154
+ if (fp.paramNames.length > 0) {
1155
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1156
+ for (let delta = 0; delta <= 80; delta++) {
1157
+ const fwd = fp.lineNo - 1 + delta;
1158
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1159
+ fp.lineNo = fwd + 1;
1160
+ break;
1161
+ }
1162
+ if (delta > 0 && delta <= 10) {
1163
+ const bwd = fp.lineNo - 1 - delta;
1164
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1165
+ fp.lineNo = bwd + 1;
1166
+ break;
1167
+ }
1168
+ }
1169
+ }
1170
+ }
1171
+ }
879
1172
  }
880
1173
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1174
+ 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;
881
1175
  const importLines = [
882
1176
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
883
1177
  ];
884
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
1178
+ if (needsTracing && isSSR) {
1179
+ // SSR/Node.js — use file system for writing
885
1180
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
886
1181
  }
887
1182
  const prefixLines = [
@@ -902,23 +1197,32 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
902
1197
  ` return __trickle_wrapFn(fn, opts);`,
903
1198
  `}`,
904
1199
  ];
1200
+ // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
1201
+ if (needsTracing) {
1202
+ if (isSSR) {
1203
+ // SSR/Node.js mode — write directly to file system
1204
+ 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) {}`, `}`);
1205
+ }
1206
+ else {
1207
+ // Browser mode — buffer and send via Vite HMR WebSocket
1208
+ 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);`, ` }`, `}`);
1209
+ }
1210
+ }
905
1211
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
906
- // Uses synchronous writes (appendFileSync) to guarantee data persists even if Vitest
907
- // kills the worker abruptly without firing exit events.
908
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
909
- 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) {} }`);
1212
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1213
+ 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) {} }`);
910
1214
  }
911
1215
  // Add React component render tracker if needed
912
1216
  if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
913
- 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) {}`, `}`);
1217
+ 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) {}`, `}`);
914
1218
  }
915
1219
  // Add React hook tracker if needed
916
1220
  if (hookInsertions.length > 0) {
917
- 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);`, ` };`, `}`);
1221
+ 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);`, ` };`, `}`);
918
1222
  }
919
1223
  // Add useState setter tracker if needed
920
1224
  if (stateInsertions.length > 0) {
921
- 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);`, ` };`, `}`);
1225
+ 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);`, ` };`, `}`);
922
1226
  }
923
1227
  prefixLines.push('');
924
1228
  const prefix = prefixLines.join('\n');
@@ -943,6 +1247,22 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
943
1247
  code: `\n;try{${calls}}catch(__e){}\n`,
944
1248
  });
945
1249
  }
1250
+ // For-loop variable insertions: insert trace at start of loop body
1251
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1252
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1253
+ allInsertions.push({
1254
+ position: bodyStart,
1255
+ code: `\ntry{${calls}}catch(__e){}\n`,
1256
+ });
1257
+ }
1258
+ // Function parameter insertions: insert trace at start of function body
1259
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1260
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1261
+ allInsertions.push({
1262
+ position: bodyStart,
1263
+ code: `\ntry{${calls}}catch(__e){}\n`,
1264
+ });
1265
+ }
946
1266
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
947
1267
  allInsertions.push({
948
1268
  position,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.68",
3
+ "version": "0.2.70",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -62,7 +62,8 @@ export async function transform({ src, filename, options }: MetroTransformArgs)
62
62
 
63
63
  if ((isReactFile || isJsFile) && !filename.includes('node_modules') && !filename.includes('trickle-observe')) {
64
64
  const moduleName = path.basename(filename).replace(/\.[jt]sx?$/, '');
65
- const transformed = transformEsmSource(src, filename, moduleName, backendUrl, debug, false);
65
+ // React Native has a JS runtime with fs access, use SSR mode
66
+ const transformed = transformEsmSource(src, filename, moduleName, backendUrl, debug, false, null, true);
66
67
  if (transformed !== src) {
67
68
  if (debug) {
68
69
  console.log(`[trickle/metro] Instrumented ${filename}`);
@@ -43,7 +43,8 @@ export default function trickleNextLoader(this: { resourcePath: string; getOptio
43
43
  const moduleName = path.basename(resourcePath).replace(/\.[jt]sx?$/, '');
44
44
 
45
45
  try {
46
- const transformed = transformEsmSource(source, resourcePath, moduleName, backendUrl, debug, traceVars, source);
46
+ // Next.js SSR renders all components on the server, so use SSR mode (node:fs)
47
+ const transformed = transformEsmSource(source, resourcePath, moduleName, backendUrl, debug, traceVars, source, true);
47
48
  if (debug && transformed !== source) {
48
49
  console.log(`[trickle/next] Instrumented ${resourcePath}`);
49
50
  }