trickle-observe 0.2.67 → 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.
- package/dist/metro-transformer.js +2 -1
- package/dist/next-loader.js +2 -1
- package/dist/vite-plugin.d.ts +5 -2
- package/dist/vite-plugin.js +46 -11
- package/dist-esm/vite-plugin.js +46 -11
- package/observe-esm-hooks.mjs +66 -16
- package/package.json +1 -1
- package/src/metro-transformer.ts +2 -1
- package/src/next-loader.ts +2 -1
- package/src/vite-plugin.ts +73 -26
|
@@ -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
|
-
|
|
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}`);
|
package/dist/next-loader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/vite-plugin.d.ts
CHANGED
|
@@ -32,10 +32,13 @@ export interface TricklePluginOptions {
|
|
|
32
32
|
export declare function tricklePlugin(options?: TricklePluginOptions): {
|
|
33
33
|
name: string;
|
|
34
34
|
enforce: "post";
|
|
35
|
-
|
|
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;
|
package/dist/vite-plugin.js
CHANGED
|
@@ -67,9 +67,33 @@ function tricklePlugin(options = {}) {
|
|
|
67
67
|
return {
|
|
68
68
|
name: 'trickle-observe',
|
|
69
69
|
enforce: 'post',
|
|
70
|
-
|
|
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 (
|
|
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();`, `
|
|
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
|
|
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);`, `
|
|
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]';`, `
|
|
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');
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -60,9 +60,33 @@ export function tricklePlugin(options = {}) {
|
|
|
60
60
|
return {
|
|
61
61
|
name: 'trickle-observe',
|
|
62
62
|
enforce: 'post',
|
|
63
|
-
|
|
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 (
|
|
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();`, `
|
|
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
|
|
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);`, `
|
|
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]';`, `
|
|
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/observe-esm-hooks.mjs
CHANGED
|
@@ -523,7 +523,21 @@ function transformSource(source, url, originalSource) {
|
|
|
523
523
|
const paramNames = parenPos >= 0 ? extractParamNamesFromSource(source, parenPos) : [];
|
|
524
524
|
// Remove 'export ' prefix, keep the function
|
|
525
525
|
result.push(line.replace(/^(\s*)export\s+/, '$1'));
|
|
526
|
-
exportedFunctions.push({ name, paramNames });
|
|
526
|
+
exportedFunctions.push({ name, paramNames, wrapInPlace: true });
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Non-exported function declarations — wrap them too for entry module coverage
|
|
531
|
+
const plainFuncMatch = trimmed.match(/^(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(/);
|
|
532
|
+
if (plainFuncMatch && !trimmed.startsWith('export')) {
|
|
533
|
+
const name = plainFuncMatch[2];
|
|
534
|
+
if (name !== 'require' && name !== 'exports' && name !== 'module' && !name.startsWith('__trickle')) {
|
|
535
|
+
const srcOffset = lineOffset(source, i) + (line.length - trimmed.length);
|
|
536
|
+
const parenPos = source.indexOf('(', srcOffset + plainFuncMatch[0].indexOf('('));
|
|
537
|
+
const paramNames = parenPos >= 0 ? extractParamNamesFromSource(source, parenPos) : [];
|
|
538
|
+
exportedFunctions.push({ name, paramNames, wrapInPlace: true, noExport: true });
|
|
539
|
+
}
|
|
540
|
+
result.push(line);
|
|
527
541
|
continue;
|
|
528
542
|
}
|
|
529
543
|
|
|
@@ -607,23 +621,58 @@ function transformSource(source, url, originalSource) {
|
|
|
607
621
|
return source;
|
|
608
622
|
}
|
|
609
623
|
|
|
610
|
-
// Add wrapper import and wrapping code
|
|
624
|
+
// Add wrapper import and wrapping code
|
|
611
625
|
const wrapperPathEscaped = config.wrapperPath.replace(/\\/g, '\\\\');
|
|
612
626
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
627
|
+
// Insert wrapper setup at the top (after imports, before user code)
|
|
628
|
+
// Using import + createRequire to load CJS wrapper from ESM context
|
|
629
|
+
const wrapSetup = [
|
|
630
|
+
'',
|
|
631
|
+
'// [trickle] Auto-observation wrappers',
|
|
632
|
+
`import { createRequire as __cr } from 'node:module';`,
|
|
633
|
+
`const __require = __cr(import.meta.url);`,
|
|
634
|
+
`const { wrapFunction: __tw } = __require('${wrapperPathEscaped}');`,
|
|
635
|
+
`const __twOpts = (name, paramNames) => { const o = { functionName: name, module: '${moduleName}', trackArgs: true, trackReturn: true, sampleRate: 1, maxDepth: 5, environment: process.env.TRICKLE_ENV || 'development', enabled: true }; if (paramNames && paramNames.length) o.paramNames = paramNames; return o; };`,
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
// For in-place wrapping: insert wrapper calls right after each function declaration
|
|
639
|
+
// This ensures functions are wrapped BEFORE any top-level code calls them
|
|
640
|
+
const inPlaceWraps = [];
|
|
641
|
+
for (const fn of exportedFunctions) {
|
|
642
|
+
if (fn.wrapInPlace) {
|
|
643
|
+
const pn = fn.paramNames.length > 0 ? JSON.stringify(fn.paramNames) : 'null';
|
|
644
|
+
inPlaceWraps.push(`try{${fn.name}=__tw(${fn.name},__twOpts('${fn.name}',${pn}))}catch(__e){}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
619
647
|
|
|
620
|
-
//
|
|
648
|
+
// Find the right position to insert the wrap setup + in-place wraps
|
|
649
|
+
// Insert after the last import/function-declaration block, before top-level code
|
|
650
|
+
let insertIdx = 0;
|
|
651
|
+
for (let j = 0; j < result.length; j++) {
|
|
652
|
+
const t = result[j].trimStart();
|
|
653
|
+
if (t.startsWith('import ') || t.startsWith('// [trickle]') || t === '' ||
|
|
654
|
+
t.startsWith('function ') || t.startsWith('async function ') ||
|
|
655
|
+
t.startsWith('const __trickle_tv') || t.startsWith('const __require_tv') ||
|
|
656
|
+
t.startsWith('const __tv_mod') || t.startsWith('if (typeof __tv_mod')) {
|
|
657
|
+
insertIdx = j + 1;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// But make sure we're after any function body closing braces too
|
|
661
|
+
// Simple heuristic: find the last line that's just '}' before any assignment/call
|
|
662
|
+
for (let j = insertIdx; j < result.length; j++) {
|
|
663
|
+
const t = result[j].trim();
|
|
664
|
+
if (t === '}') insertIdx = j + 1;
|
|
665
|
+
else if (t && !t.startsWith('//') && !t.startsWith('function') && !t.startsWith('async function')) break;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
result.splice(insertIdx, 0, ...wrapSetup, ...inPlaceWraps, '');
|
|
669
|
+
|
|
670
|
+
// Re-export wrapped functions (for importers of this module)
|
|
621
671
|
const reExports = [];
|
|
622
|
-
for (const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
reExports.push(`${wrappedName} as ${name}`);
|
|
672
|
+
for (const fn of exportedFunctions) {
|
|
673
|
+
if (!fn.noExport) {
|
|
674
|
+
reExports.push(`${fn.name}`);
|
|
675
|
+
}
|
|
627
676
|
}
|
|
628
677
|
|
|
629
678
|
// Handle named exports from export { } statements
|
|
@@ -703,10 +752,11 @@ export async function load(url, context, nextLoad) {
|
|
|
703
752
|
? source
|
|
704
753
|
: Buffer.from(source).toString('utf-8');
|
|
705
754
|
|
|
706
|
-
// Only transform if the module has exports or variable declarations to trace
|
|
755
|
+
// Only transform if the module has exports, function declarations, or variable declarations to trace
|
|
707
756
|
const varTraceEnabled = process.env.TRICKLE_TRACE_VARS !== '0' && config.traceVarPath;
|
|
708
757
|
const hasVarDecls = varTraceEnabled && /^[ \t]*(?:export\s+)?(?:const|let|var)\s+[a-zA-Z_$]/m.test(sourceStr);
|
|
709
|
-
|
|
758
|
+
const hasFuncDecls = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+\w/m.test(sourceStr);
|
|
759
|
+
if (!sourceStr.includes('export ') && !hasVarDecls && !hasFuncDecls) return result;
|
|
710
760
|
|
|
711
761
|
try {
|
|
712
762
|
const transformed = transformSource(sourceStr, url);
|
package/package.json
CHANGED
package/src/metro-transformer.ts
CHANGED
|
@@ -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
|
-
|
|
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}`);
|
package/src/next-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/vite-plugin.ts
CHANGED
|
@@ -78,9 +78,33 @@ export function tricklePlugin(options: TricklePluginOptions = {}) {
|
|
|
78
78
|
name: 'trickle-observe',
|
|
79
79
|
enforce: 'post' as const,
|
|
80
80
|
|
|
81
|
-
|
|
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 (
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
` };`,
|