trickle-observe 0.2.68 → 0.2.69

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
  },
@@ -520,7 +544,7 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
520
544
  function escapeRegexStr(str) {
521
545
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
522
546
  }
523
- function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
547
+ function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
524
548
  // Detect React files for component render tracking
525
549
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
526
550
  // Match top-level and nested function declarations (including async, export, export default)
@@ -885,10 +909,12 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
885
909
  }
886
910
  }
887
911
  // Build prefix — ALL imports first (ESM requires imports before any statements)
912
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
888
913
  const importLines = [
889
914
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
890
915
  ];
891
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
916
+ if (needsTracing && isSSR) {
917
+ // SSR/Node.js — use file system for writing
892
918
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
893
919
  }
894
920
  const prefixLines = [
@@ -909,23 +935,32 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
909
935
  ` return __trickle_wrapFn(fn, opts);`,
910
936
  `}`,
911
937
  ];
938
+ // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
939
+ if (needsTracing) {
940
+ if (isSSR) {
941
+ // SSR/Node.js mode — write directly to file system
942
+ 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) {}`, `}`);
943
+ }
944
+ else {
945
+ // Browser mode — buffer and send via Vite HMR WebSocket
946
+ 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);`, ` }`, `}`);
947
+ }
948
+ }
912
949
  // 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
950
  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) {} }`);
951
+ 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
952
  }
918
953
  // Add React component render tracker if needed
919
954
  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) {}`, `}`);
955
+ 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
956
  }
922
957
  // Add React hook tracker if needed
923
958
  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);`, ` };`, `}`);
959
+ 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
960
  }
926
961
  // Add useState setter tracker if needed
927
962
  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);`, ` };`, `}`);
963
+ 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
964
  }
930
965
  prefixLines.push('');
931
966
  const prefix = prefixLines.join('\n');
@@ -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
  },
@@ -513,7 +537,7 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
513
537
  function escapeRegexStr(str) {
514
538
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
515
539
  }
516
- export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
540
+ export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource, isSSR) {
517
541
  // Detect React files for component render tracking
518
542
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
519
543
  // Match top-level and nested function declarations (including async, export, export default)
@@ -878,10 +902,12 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
878
902
  }
879
903
  }
880
904
  // Build prefix — ALL imports first (ESM requires imports before any statements)
905
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
881
906
  const importLines = [
882
907
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
883
908
  ];
884
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
909
+ if (needsTracing && isSSR) {
910
+ // SSR/Node.js — use file system for writing
885
911
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
886
912
  }
887
913
  const prefixLines = [
@@ -902,23 +928,32 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
902
928
  ` return __trickle_wrapFn(fn, opts);`,
903
929
  `}`,
904
930
  ];
931
+ // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
932
+ if (needsTracing) {
933
+ if (isSSR) {
934
+ // SSR/Node.js mode — write directly to file system
935
+ 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) {}`, `}`);
936
+ }
937
+ else {
938
+ // Browser mode — buffer and send via Vite HMR WebSocket
939
+ 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);`, ` }`, `}`);
940
+ }
941
+ }
905
942
  // 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
943
  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) {} }`);
944
+ 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
945
  }
911
946
  // Add React component render tracker if needed
912
947
  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) {}`, `}`);
948
+ 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
949
  }
915
950
  // Add React hook tracker if needed
916
951
  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);`, ` };`, `}`);
952
+ 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
953
  }
919
954
  // Add useState setter tracker if needed
920
955
  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);`, ` };`, `}`);
956
+ 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
957
  }
923
958
  prefixLines.push('');
924
959
  const prefix = prefixLines.join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.68",
3
+ "version": "0.2.69",
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
  }
@@ -78,9 +78,33 @@ export function tricklePlugin(options: TricklePluginOptions = {}) {
78
78
  name: 'trickle-observe',
79
79
  enforce: 'post' as const,
80
80
 
81
- transform(code: string, id: string) {
81
+ configureServer(server: any) {
82
+ // Listen for variable data from browser clients via Vite's HMR WebSocket
83
+ const hot = server.hot || server.ws;
84
+ if (hot && hot.on) {
85
+ const varDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
86
+ try { fs.mkdirSync(varDir, { recursive: true }); } catch {}
87
+ const varsFile = path.join(varDir, 'variables.jsonl');
88
+
89
+ hot.on('trickle:vars', (data: { lines: string }, client: any) => {
90
+ try {
91
+ if (data && data.lines) {
92
+ fs.appendFileSync(varsFile, data.lines);
93
+ }
94
+ } catch {}
95
+ });
96
+
97
+ if (debug) {
98
+ console.log(`[trickle/vite] WebSocket bridge active → ${varsFile}`);
99
+ }
100
+ }
101
+ },
102
+
103
+ transform(code: string, id: string, options?: { ssr?: boolean }) {
82
104
  if (!shouldTransform(id)) return null;
83
105
 
106
+ const isSSR = options?.ssr === true;
107
+
84
108
  // Read the original source file to get accurate line numbers.
85
109
  // Vite transforms the code before our plugin (enforce: 'post'),
86
110
  // so line numbers from `code` don't match the original .ts file.
@@ -92,11 +116,11 @@ export function tricklePlugin(options: TricklePluginOptions = {}) {
92
116
  }
93
117
 
94
118
  const moduleName = path.basename(id).replace(/\.[jt]sx?$/, '');
95
- const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource);
119
+ const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource, isSSR);
96
120
  if (transformed === code) return null;
97
121
 
98
122
  if (debug) {
99
- console.log(`[trickle/vite] Transformed ${moduleName} (${id})`);
123
+ console.log(`[trickle/vite] Transformed ${moduleName} (${id}) [${isSSR ? 'SSR' : 'browser'}]`);
100
124
  }
101
125
 
102
126
  return { code: transformed, map: null };
@@ -514,6 +538,7 @@ export function transformEsmSource(
514
538
  debug: boolean,
515
539
  traceVars: boolean,
516
540
  originalSource?: string | null,
541
+ isSSR?: boolean,
517
542
  ): string {
518
543
  // Detect React files for component render tracking
519
544
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
@@ -875,10 +900,12 @@ export function transformEsmSource(
875
900
  }
876
901
 
877
902
  // Build prefix — ALL imports first (ESM requires imports before any statements)
903
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
878
904
  const importLines: string[] = [
879
905
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
880
906
  ];
881
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
907
+ if (needsTracing && isSSR) {
908
+ // SSR/Node.js — use file system for writing
882
909
  importLines.push(
883
910
  `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
884
911
  `import { join as __trickle_join } from 'node:path';`,
@@ -904,14 +931,49 @@ export function transformEsmSource(
904
931
  `}`,
905
932
  ];
906
933
 
934
+ // Add unified __trickle_send() transport — browser uses HMR WebSocket, SSR uses fs
935
+ if (needsTracing) {
936
+ if (isSSR) {
937
+ // SSR/Node.js mode — write directly to file system
938
+ prefixLines.push(
939
+ `let __trickle_varsFile = null;`,
940
+ `function __trickle_send(line) {`,
941
+ ` try {`,
942
+ ` if (!__trickle_varsFile) {`,
943
+ ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
944
+ ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
945
+ ` __trickle_varsFile = __trickle_join(dir, 'variables.jsonl');`,
946
+ ` }`,
947
+ ` __trickle_appendFileSync(__trickle_varsFile, line + '\\n');`,
948
+ ` } catch(e) {}`,
949
+ `}`,
950
+ );
951
+ } else {
952
+ // Browser mode — buffer and send via Vite HMR WebSocket
953
+ prefixLines.push(
954
+ `const __trickle_sendBuf = [];`,
955
+ `let __trickle_sendTimer = null;`,
956
+ `function __trickle_flush() {`,
957
+ ` if (__trickle_sendBuf.length === 0) return;`,
958
+ ` const lines = __trickle_sendBuf.join('\\n') + '\\n';`,
959
+ ` __trickle_sendBuf.length = 0;`,
960
+ ` try { if (import.meta.hot) import.meta.hot.send('trickle:vars', { lines }); } catch(e) {}`,
961
+ `}`,
962
+ `function __trickle_send(line) {`,
963
+ ` __trickle_sendBuf.push(line);`,
964
+ ` if (!__trickle_sendTimer) {`,
965
+ ` __trickle_sendTimer = setTimeout(() => { __trickle_sendTimer = null; __trickle_flush(); }, 300);`,
966
+ ` }`,
967
+ `}`,
968
+ );
969
+ }
970
+ }
971
+
907
972
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
908
- // Uses synchronous writes (appendFileSync) to guarantee data persists even if Vitest
909
- // kills the worker abruptly without firing exit events.
910
973
  if (varInsertions.length > 0 || destructInsertions.length > 0) {
911
974
  prefixLines.push(
912
975
  `if (!globalThis.__trickle_var_tracer) {`,
913
976
  ` const _cache = new Set();`,
914
- ` let _varsFile = null;`,
915
977
  ` function _inferType(v, d) {`,
916
978
  ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`,
917
979
  ` if (v === null) return { kind: 'primitive', name: 'null' };`,
@@ -943,17 +1005,12 @@ export function transformEsmSource(
943
1005
  ` }`,
944
1006
  ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`,
945
1007
  ` try {`,
946
- ` if (!_varsFile) {`,
947
- ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
948
- ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
949
- ` _varsFile = __trickle_join(dir, 'variables.jsonl');`,
950
- ` }`,
951
1008
  ` const type = _inferType(v, 3);`,
952
1009
  ` const th = JSON.stringify(type).substring(0, 32);`,
953
1010
  ` const ck = file + ':' + l + ':' + n + ':' + th;`,
954
1011
  ` if (_cache.has(ck)) return;`,
955
1012
  ` _cache.add(ck);`,
956
- ` __trickle_appendFileSync(_varsFile, JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }) + '\\n');`,
1013
+ ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }));`,
957
1014
  ` } catch(e) {}`,
958
1015
  ` };`,
959
1016
  `}`,
@@ -971,9 +1028,6 @@ export function transformEsmSource(
971
1028
  ` const key = ${JSON.stringify(filename)} + ':' + line;`,
972
1029
  ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`,
973
1030
  ` globalThis.__trickle_react_renders.set(key, count);`,
974
- ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
975
- ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
976
- ` const f = __trickle_join(dir, 'variables.jsonl');`,
977
1031
  ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`,
978
1032
  ` if (props !== undefined && props !== null && typeof props === 'object') {`,
979
1033
  ` try {`,
@@ -991,7 +1045,6 @@ export function transformEsmSource(
991
1045
  ` }`,
992
1046
  ` rec.props = propSample;`,
993
1047
  ` rec.propKeys = propKeys;`,
994
- ` // Detect which props changed vs previous render`,
995
1048
  ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`,
996
1049
  ` if (prevProps && count > 1) {`,
997
1050
  ` const changedProps = [];`,
@@ -1008,7 +1061,7 @@ export function transformEsmSource(
1008
1061
  ` globalThis.__trickle_react_prev_props.set(key, propSample);`,
1009
1062
  ` } catch(e2) {}`,
1010
1063
  ` }`,
1011
- ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`,
1064
+ ` __trickle_send(JSON.stringify(rec));`,
1012
1065
  ` } catch(e) {}`,
1013
1066
  `}`,
1014
1067
  );
@@ -1024,10 +1077,7 @@ export function transformEsmSource(
1024
1077
  ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`,
1025
1078
  ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`,
1026
1079
  ` globalThis.__trickle_hook_counts.set(key, n);`,
1027
- ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
1028
- ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
1029
- ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
1030
- ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`,
1080
+ ` __trickle_send(JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }));`,
1031
1081
  ` } catch(e) {}`,
1032
1082
  ` return cb(...args);`,
1033
1083
  ` };`,
@@ -1053,10 +1103,7 @@ export function transformEsmSource(
1053
1103
  ` else if (newVal === null || newVal === undefined) sample = newVal;`,
1054
1104
  ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`,
1055
1105
  ` else sample = '[object]';`,
1056
- ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
1057
- ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
1058
- ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
1059
- ` JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`,
1106
+ ` __trickle_send(JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }));`,
1060
1107
  ` } catch(e) {}`,
1061
1108
  ` return origSetter(newVal);`,
1062
1109
  ` };`,