trickle-observe 0.2.33 → 0.2.35

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/auto-esm.mjs CHANGED
@@ -39,6 +39,7 @@ register(pathToFileURL(hooksPath).href, {
39
39
  wrapperPath: join(__dirname, 'dist', 'wrap.js'),
40
40
  transportPath: join(__dirname, 'dist', 'transport.js'),
41
41
  envDetectPath: join(__dirname, 'dist', 'env-detect.js'),
42
+ traceVarPath: join(__dirname, 'dist', 'trace-var.js'),
42
43
  backendUrl: 'http://localhost:4888', // unused in local mode but configure() needs it
43
44
  debug,
44
45
  includePatterns: process.env.TRICKLE_OBSERVE_INCLUDE
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Vite plugin for trickle observation.
3
+ *
4
+ * Integrates into Vite's (and Vitest's) transform pipeline to wrap
5
+ * user functions with trickle observation AND trace variable assignments —
6
+ * the same thing observe-register.ts does for Node's Module._compile,
7
+ * but for Vite/Vitest.
8
+ *
9
+ * Usage in vitest.config.ts:
10
+ *
11
+ * import { tricklePlugin } from 'trickle-observe/vite-plugin';
12
+ * export default defineConfig({
13
+ * plugins: [tricklePlugin()],
14
+ * });
15
+ *
16
+ * Or via CLI:
17
+ *
18
+ * trickle run vitest run tests/
19
+ */
20
+ import path from 'path';
21
+ import fs from 'fs';
22
+ export function tricklePlugin(options = {}) {
23
+ const include = options.include
24
+ ?? (process.env.TRICKLE_OBSERVE_INCLUDE
25
+ ? process.env.TRICKLE_OBSERVE_INCLUDE.split(',').map(s => s.trim())
26
+ : []);
27
+ const exclude = options.exclude
28
+ ?? (process.env.TRICKLE_OBSERVE_EXCLUDE
29
+ ? process.env.TRICKLE_OBSERVE_EXCLUDE.split(',').map(s => s.trim())
30
+ : []);
31
+ const backendUrl = options.backendUrl
32
+ ?? process.env.TRICKLE_BACKEND_URL
33
+ ?? 'http://localhost:4888';
34
+ const debug = options.debug
35
+ ?? (process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true');
36
+ const traceVars = options.traceVars ?? (process.env.TRICKLE_TRACE_VARS !== '0');
37
+ function shouldTransform(id) {
38
+ // Only JS/TS files
39
+ const ext = path.extname(id).toLowerCase();
40
+ if (!['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts'].includes(ext))
41
+ return false;
42
+ // Skip node_modules
43
+ if (id.includes('node_modules'))
44
+ return false;
45
+ // Skip trickle internals
46
+ if (id.includes('trickle-observe') || id.includes('client-js/'))
47
+ return false;
48
+ // Include filter
49
+ if (include.length > 0) {
50
+ if (!include.some(p => id.includes(p)))
51
+ return false;
52
+ }
53
+ // Exclude filter
54
+ if (exclude.length > 0) {
55
+ if (exclude.some(p => id.includes(p)))
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ return {
61
+ name: 'trickle-observe',
62
+ enforce: 'post',
63
+ transform(code, id) {
64
+ if (!shouldTransform(id))
65
+ return null;
66
+ // Read the original source file to get accurate line numbers.
67
+ // Vite transforms the code before our plugin (enforce: 'post'),
68
+ // so line numbers from `code` don't match the original .ts file.
69
+ let originalSource = null;
70
+ try {
71
+ originalSource = fs.readFileSync(id, 'utf-8');
72
+ }
73
+ catch {
74
+ // If we can't read the original, we'll use transformed line numbers
75
+ }
76
+ const moduleName = path.basename(id).replace(/\.[jt]sx?$/, '');
77
+ const transformed = transformEsmSource(code, id, moduleName, backendUrl, debug, traceVars, originalSource);
78
+ if (transformed === code)
79
+ return null;
80
+ if (debug) {
81
+ console.log(`[trickle/vite] Transformed ${moduleName} (${id})`);
82
+ }
83
+ return { code: transformed, map: null };
84
+ },
85
+ };
86
+ }
87
+ /**
88
+ * Find the closing brace position for a function body starting at `openBrace`.
89
+ */
90
+ function findClosingBrace(source, openBrace) {
91
+ let depth = 1;
92
+ let pos = openBrace + 1;
93
+ while (pos < source.length && depth > 0) {
94
+ const ch = source[pos];
95
+ if (ch === '{') {
96
+ depth++;
97
+ }
98
+ else if (ch === '}') {
99
+ depth--;
100
+ if (depth === 0)
101
+ return pos;
102
+ }
103
+ else if (ch === '"' || ch === "'" || ch === '`') {
104
+ const quote = ch;
105
+ pos++;
106
+ while (pos < source.length) {
107
+ if (source[pos] === '\\') {
108
+ pos++;
109
+ }
110
+ else if (source[pos] === quote)
111
+ break;
112
+ else if (quote === '`' && source[pos] === '$' && pos + 1 < source.length && source[pos + 1] === '{') {
113
+ pos += 2;
114
+ let tDepth = 1;
115
+ while (pos < source.length && tDepth > 0) {
116
+ if (source[pos] === '{')
117
+ tDepth++;
118
+ else if (source[pos] === '}')
119
+ tDepth--;
120
+ pos++;
121
+ }
122
+ continue;
123
+ }
124
+ pos++;
125
+ }
126
+ }
127
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
128
+ while (pos < source.length && source[pos] !== '\n')
129
+ pos++;
130
+ }
131
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
132
+ pos += 2;
133
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/'))
134
+ pos++;
135
+ pos++;
136
+ }
137
+ pos++;
138
+ }
139
+ return -1;
140
+ }
141
+ /**
142
+ * Find variable declarations in source and return insertions for tracing.
143
+ * Handles: const x = ...; let x = ...; var x = ...;
144
+ * Skips: destructuring, for-loop vars, require() calls, imports, type annotations.
145
+ */
146
+ function findVarDeclarations(source) {
147
+ const varInsertions = [];
148
+ // Match: const/let/var <identifier> = <something>
149
+ const varRegex = /^([ \t]*)(export\s+)?(const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=[^=]/gm;
150
+ let vmatch;
151
+ while ((vmatch = varRegex.exec(source)) !== null) {
152
+ const varName = vmatch[4];
153
+ // Skip trickle internals
154
+ if (varName.startsWith('__trickle'))
155
+ continue;
156
+ // Skip TS compiled vars
157
+ if (varName === '_a' || varName === '_b' || varName === '_c')
158
+ continue;
159
+ // Check if this is a require() call or import — skip those
160
+ const restOfLine = source.slice(vmatch.index + vmatch[0].length - 1, vmatch.index + vmatch[0].length + 200);
161
+ if (/^\s*require\s*\(/.test(restOfLine))
162
+ continue;
163
+ // Skip function/class assignments (those are handled by function wrapping)
164
+ if (/^\s*(?:async\s+)?(?:function\s|\([^)]*\)\s*(?::\s*[^=]+?)?\s*=>|\w+\s*=>)/.test(restOfLine))
165
+ continue;
166
+ // Calculate line number
167
+ let lineNo = 1;
168
+ for (let i = 0; i < vmatch.index; i++) {
169
+ if (source[i] === '\n')
170
+ lineNo++;
171
+ }
172
+ // Find the end of this statement
173
+ const startPos = vmatch.index + vmatch[0].length - 1;
174
+ let pos = startPos;
175
+ let depth = 0;
176
+ let foundEnd = -1;
177
+ while (pos < source.length) {
178
+ const ch = source[pos];
179
+ if (ch === '(' || ch === '[' || ch === '{') {
180
+ depth++;
181
+ }
182
+ else if (ch === ')' || ch === ']' || ch === '}') {
183
+ depth--;
184
+ if (depth < 0)
185
+ break;
186
+ }
187
+ else if (ch === ';' && depth === 0) {
188
+ foundEnd = pos;
189
+ break;
190
+ }
191
+ else if (ch === '\n' && depth === 0) {
192
+ const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
193
+ if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
194
+ foundEnd = pos;
195
+ break;
196
+ }
197
+ }
198
+ else if (ch === '"' || ch === "'" || ch === '`') {
199
+ const quote = ch;
200
+ pos++;
201
+ while (pos < source.length) {
202
+ if (source[pos] === '\\') {
203
+ pos++;
204
+ }
205
+ else if (source[pos] === quote)
206
+ break;
207
+ pos++;
208
+ }
209
+ }
210
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
211
+ while (pos < source.length && source[pos] !== '\n')
212
+ pos++;
213
+ continue;
214
+ }
215
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
216
+ pos += 2;
217
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/'))
218
+ pos++;
219
+ pos++;
220
+ }
221
+ pos++;
222
+ }
223
+ if (foundEnd === -1)
224
+ continue;
225
+ varInsertions.push({ lineEnd: foundEnd + 1, varName, lineNo });
226
+ }
227
+ return varInsertions;
228
+ }
229
+ /**
230
+ * Find destructured variable declarations: const { a, b } = ... and const [a, b] = ...
231
+ * Extracts the individual variable names from the destructuring pattern.
232
+ */
233
+ function findDestructuredDeclarations(source) {
234
+ const results = [];
235
+ // Match: const/let/var { ... } = ... or const/let/var [ ... ] = ...
236
+ const destructRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\])\s*(?::\s*[^=]+?)?\s*=[^=]/gm;
237
+ let match;
238
+ while ((match = destructRegex.exec(source)) !== null) {
239
+ const pattern = match[1];
240
+ // Extract variable names from the destructuring pattern
241
+ const varNames = extractDestructuredNames(pattern);
242
+ if (varNames.length === 0)
243
+ continue;
244
+ // Skip if it's a require() call
245
+ const restOfLine = source.slice(match.index + match[0].length - 1, match.index + match[0].length + 200);
246
+ if (/^\s*require\s*\(/.test(restOfLine))
247
+ continue;
248
+ // Calculate line number
249
+ let lineNo = 1;
250
+ for (let i = 0; i < match.index; i++) {
251
+ if (source[i] === '\n')
252
+ lineNo++;
253
+ }
254
+ // Find the end of this statement (same logic as findVarDeclarations)
255
+ const startPos = match.index + match[0].length - 1;
256
+ let pos = startPos;
257
+ let depth = 0;
258
+ let foundEnd = -1;
259
+ while (pos < source.length) {
260
+ const ch = source[pos];
261
+ if (ch === '(' || ch === '[' || ch === '{') {
262
+ depth++;
263
+ }
264
+ else if (ch === ')' || ch === ']' || ch === '}') {
265
+ depth--;
266
+ if (depth < 0)
267
+ break;
268
+ }
269
+ else if (ch === ';' && depth === 0) {
270
+ foundEnd = pos;
271
+ break;
272
+ }
273
+ else if (ch === '\n' && depth === 0) {
274
+ const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
275
+ if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
276
+ foundEnd = pos;
277
+ break;
278
+ }
279
+ }
280
+ else if (ch === '"' || ch === "'" || ch === '`') {
281
+ const quote = ch;
282
+ pos++;
283
+ while (pos < source.length) {
284
+ if (source[pos] === '\\') {
285
+ pos++;
286
+ }
287
+ else if (source[pos] === quote)
288
+ break;
289
+ pos++;
290
+ }
291
+ }
292
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
293
+ while (pos < source.length && source[pos] !== '\n')
294
+ pos++;
295
+ continue;
296
+ }
297
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
298
+ pos += 2;
299
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/'))
300
+ pos++;
301
+ pos++;
302
+ }
303
+ pos++;
304
+ }
305
+ if (foundEnd === -1)
306
+ continue;
307
+ results.push({ lineEnd: foundEnd + 1, varNames, lineNo });
308
+ }
309
+ return results;
310
+ }
311
+ /**
312
+ * Extract variable names from a destructuring pattern.
313
+ * Handles: { a, b, c: d } → ['a', 'b', 'd'] (renamed vars use the local name)
314
+ * Handles: [a, b, ...rest] → ['a', 'b', 'rest']
315
+ * Handles: { a: { b, c } } → ['b', 'c'] (nested destructuring)
316
+ */
317
+ function extractDestructuredNames(pattern) {
318
+ const names = [];
319
+ // Remove outer braces/brackets
320
+ const inner = pattern.slice(1, -1).trim();
321
+ if (!inner)
322
+ return names;
323
+ // Split by commas at depth 0
324
+ const parts = [];
325
+ let depth = 0;
326
+ let current = '';
327
+ for (const ch of inner) {
328
+ if (ch === '{' || ch === '[')
329
+ depth++;
330
+ else if (ch === '}' || ch === ']')
331
+ depth--;
332
+ else if (ch === ',' && depth === 0) {
333
+ parts.push(current.trim());
334
+ current = '';
335
+ continue;
336
+ }
337
+ current += ch;
338
+ }
339
+ if (current.trim())
340
+ parts.push(current.trim());
341
+ for (let part of parts) {
342
+ // Remove type annotations: `a: Type` vs `a: b` (rename)
343
+ // Skip rest elements for now: ...rest → rest
344
+ if (part.startsWith('...')) {
345
+ const restName = part.slice(3).trim().split(/[\s:]/)[0];
346
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
347
+ names.push(restName);
348
+ }
349
+ continue;
350
+ }
351
+ // Check for rename pattern: key: localName or key: { nested }
352
+ const colonIdx = part.indexOf(':');
353
+ if (colonIdx !== -1) {
354
+ const afterColon = part.slice(colonIdx + 1).trim();
355
+ // Nested destructuring: key: { a, b } or key: [a, b]
356
+ if (afterColon.startsWith('{') || afterColon.startsWith('[')) {
357
+ const nestedNames = extractDestructuredNames(afterColon);
358
+ names.push(...nestedNames);
359
+ }
360
+ else {
361
+ // Rename: key: localName — extract localName (skip if it has another colon for type annotation)
362
+ const localName = afterColon.split(/[\s=]/)[0].trim();
363
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(localName)) {
364
+ names.push(localName);
365
+ }
366
+ }
367
+ }
368
+ else {
369
+ // Simple: just the identifier (possibly with default: `a = defaultVal`)
370
+ const name = part.split(/[\s=]/)[0].trim();
371
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
372
+ names.push(name);
373
+ }
374
+ }
375
+ }
376
+ return names;
377
+ }
378
+ /**
379
+ * Transform ESM source code to wrap function declarations and trace variables.
380
+ *
381
+ * Prepends imports of the wrap/trace helpers, then inserts wrapper calls after
382
+ * each function declaration body and trace calls after variable declarations.
383
+ */
384
+ /**
385
+ * Find the original line number for a simple variable declaration.
386
+ * Searches the original source lines for `const/let/var <varName>` near the expected position.
387
+ * Vite transforms typically remove lines (types, imports), so the original line is usually
388
+ * >= the transformed line. We search forward-biased (up to +80) but also a bit backward (-10).
389
+ */
390
+ function findOriginalLine(origLines, varName, transformedLine) {
391
+ const pattern = new RegExp(`\\b(const|let|var)\\s+${escapeRegexStr(varName)}\\b`);
392
+ // Search: first try exact, then expand forward (more likely) and a bit backward
393
+ for (let delta = 0; delta <= 80; delta++) {
394
+ // Forward first (original line is usually after transformed line due to removed TS types)
395
+ const fwd = transformedLine - 1 + delta;
396
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
397
+ return fwd + 1;
398
+ }
399
+ // Also check backward (small range)
400
+ if (delta > 0 && delta <= 10) {
401
+ const bwd = transformedLine - 1 - delta;
402
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
403
+ return bwd + 1;
404
+ }
405
+ }
406
+ }
407
+ return -1;
408
+ }
409
+ /**
410
+ * Find the original line number for a destructured declaration.
411
+ * Searches for const/let/var { or [ patterns containing at least one of the variable names.
412
+ */
413
+ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
414
+ // Match names as actual bindings (not renamed property keys).
415
+ // In `{ data: customer }`, 'data' is a key (followed by ':'), 'customer' is the binding.
416
+ // In `{ data, error }`, 'data' is a binding (followed by ',' or '}').
417
+ // We check: name followed by comma, }, ], =, whitespace, or end — NOT followed by ':' (rename).
418
+ const namePatterns = varNames.map(n => new RegExp(`\\b${escapeRegexStr(n)}\\b(?!\\s*:)`));
419
+ for (let delta = 0; delta <= 80; delta++) {
420
+ const fwd = transformedLine - 1 + delta;
421
+ if (fwd >= 0 && fwd < origLines.length) {
422
+ const line = origLines[fwd];
423
+ if (/\b(const|let|var)\s+[\[{]/.test(line) && namePatterns.some(p => p.test(line))) {
424
+ return fwd + 1;
425
+ }
426
+ }
427
+ if (delta > 0 && delta <= 10) {
428
+ const bwd = transformedLine - 1 - delta;
429
+ if (bwd >= 0 && bwd < origLines.length) {
430
+ const line = origLines[bwd];
431
+ if (/\b(const|let|var)\s+[\[{]/.test(line) && namePatterns.some(p => p.test(line))) {
432
+ return bwd + 1;
433
+ }
434
+ }
435
+ }
436
+ }
437
+ return -1;
438
+ }
439
+ function escapeRegexStr(str) {
440
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
441
+ }
442
+ function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
443
+ // Match top-level and nested function declarations (including async, export)
444
+ const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
445
+ const funcInsertions = [];
446
+ let match;
447
+ while ((match = funcRegex.exec(source)) !== null) {
448
+ const name = match[1];
449
+ if (name === 'require' || name === 'exports' || name === 'module')
450
+ continue;
451
+ const afterMatch = match.index + match[0].length;
452
+ const openBrace = source.indexOf('{', afterMatch);
453
+ if (openBrace === -1)
454
+ continue;
455
+ // Extract parameter names
456
+ const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
457
+ const paramNames = paramStr
458
+ ? paramStr.split(',').map(p => {
459
+ const trimmed = p.trim().split('=')[0].trim().split(':')[0].trim();
460
+ if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('...'))
461
+ return '';
462
+ return trimmed;
463
+ }).filter(Boolean)
464
+ : [];
465
+ const closeBrace = findClosingBrace(source, openBrace);
466
+ if (closeBrace === -1)
467
+ continue;
468
+ funcInsertions.push({ position: closeBrace + 1, name, paramNames });
469
+ }
470
+ // Also match arrow functions assigned to const/let/var
471
+ const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
472
+ while ((match = arrowRegex.exec(source)) !== null) {
473
+ const name = match[1];
474
+ const openBrace = source.indexOf('{', match.index + match[0].length - 1);
475
+ if (openBrace === -1)
476
+ continue;
477
+ const arrowStr = match[0];
478
+ const arrowParamMatch = arrowStr.match(/=\s*(?:async\s+)?(?:\(([^)]*)\)|([a-zA-Z_$][a-zA-Z0-9_$]*))\s*(?::\s*[^=]+?)?\s*=>/);
479
+ let paramNames = [];
480
+ if (arrowParamMatch) {
481
+ const paramStr = (arrowParamMatch[1] || arrowParamMatch[2] || '').trim();
482
+ if (paramStr) {
483
+ paramNames = paramStr.split(',').map(p => {
484
+ const trimmed = p.trim().split('=')[0].trim().split(':')[0].trim();
485
+ if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('...'))
486
+ return '';
487
+ return trimmed;
488
+ }).filter(Boolean);
489
+ }
490
+ }
491
+ const closeBrace = findClosingBrace(source, openBrace);
492
+ if (closeBrace === -1)
493
+ continue;
494
+ funcInsertions.push({ position: closeBrace + 1, name, paramNames });
495
+ }
496
+ // Find variable declarations for tracing
497
+ const varInsertions = traceVars ? findVarDeclarations(source) : [];
498
+ // Find destructured variable declarations for tracing
499
+ const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
500
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0)
501
+ return source;
502
+ // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
503
+ // Map transformed line numbers to original source line numbers.
504
+ if (originalSource && originalSource !== source) {
505
+ const origLines = originalSource.split('\n');
506
+ // For each variable insertion, find the declaration in the original source
507
+ for (const vi of varInsertions) {
508
+ const origLine = findOriginalLine(origLines, vi.varName, vi.lineNo);
509
+ if (origLine !== -1)
510
+ vi.lineNo = origLine;
511
+ }
512
+ for (const di of destructInsertions) {
513
+ // Use the first variable name to locate the line
514
+ if (di.varNames.length > 0) {
515
+ const origLine = findOriginalLineDestructured(origLines, di.varNames, di.lineNo);
516
+ if (origLine !== -1)
517
+ di.lineNo = origLine;
518
+ }
519
+ }
520
+ }
521
+ // Build prefix — ALL imports first (ESM requires imports before any statements)
522
+ const importLines = [
523
+ `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
524
+ ];
525
+ if (varInsertions.length > 0 || destructInsertions.length > 0) {
526
+ importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
527
+ }
528
+ const prefixLines = [
529
+ ...importLines,
530
+ `__trickle_configure({ backendUrl: ${JSON.stringify(backendUrl)}, batchIntervalMs: 2000, debug: ${debug}, enabled: true, environment: 'node' });`,
531
+ `function __trickle_wrap(fn, name, paramNames) {`,
532
+ ` const opts = {`,
533
+ ` functionName: name,`,
534
+ ` module: ${JSON.stringify(moduleName)},`,
535
+ ` trackArgs: true,`,
536
+ ` trackReturn: true,`,
537
+ ` sampleRate: 1,`,
538
+ ` maxDepth: 5,`,
539
+ ` environment: 'node',`,
540
+ ` enabled: true,`,
541
+ ` };`,
542
+ ` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
543
+ ` return __trickle_wrapFn(fn, opts);`,
544
+ `}`,
545
+ ];
546
+ // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
547
+ // Uses synchronous writes (appendFileSync) to guarantee data persists even if Vitest
548
+ // kills the worker abruptly without firing exit events.
549
+ if (varInsertions.length > 0 || destructInsertions.length > 0) {
550
+ 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) {} }`);
551
+ }
552
+ prefixLines.push('');
553
+ const prefix = prefixLines.join('\n');
554
+ const allInsertions = [];
555
+ for (const { position, name, paramNames } of funcInsertions) {
556
+ const paramNamesArg = paramNames.length > 0 ? JSON.stringify(paramNames) : 'null';
557
+ allInsertions.push({
558
+ position,
559
+ code: `\ntry{${name}=__trickle_wrap(${name},'${name}',${paramNamesArg})}catch(__e){}\n`,
560
+ });
561
+ }
562
+ for (const { lineEnd, varName, lineNo } of varInsertions) {
563
+ allInsertions.push({
564
+ position: lineEnd,
565
+ code: `\n;try{__trickle_tv(${varName},${JSON.stringify(varName)},${lineNo})}catch(__e){}\n`,
566
+ });
567
+ }
568
+ for (const { lineEnd, varNames, lineNo } of destructInsertions) {
569
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
570
+ allInsertions.push({
571
+ position: lineEnd,
572
+ code: `\n;try{${calls}}catch(__e){}\n`,
573
+ });
574
+ }
575
+ // Sort by position descending (insert from end to preserve earlier positions)
576
+ allInsertions.sort((a, b) => b.position - a.position);
577
+ let result = source;
578
+ for (const { position, code } of allInsertions) {
579
+ result = result.slice(0, position) + code + result.slice(position);
580
+ }
581
+ return prefix + result;
582
+ }
583
+ export default tricklePlugin;
@@ -11,7 +11,50 @@
11
11
  */
12
12
  import { createRequire } from 'node:module';
13
13
  import { fileURLToPath } from 'node:url';
14
- import { basename, sep } from 'node:path';
14
+ import { basename, sep, extname } from 'node:path';
15
+ import { readFileSync } from 'node:fs';
16
+
17
+ // Lazy esbuild loader for JSX/TSX stripping
18
+ let _esbuild = null;
19
+ async function getEsbuild() {
20
+ if (_esbuild !== null) return _esbuild;
21
+ try {
22
+ _esbuild = await import('esbuild');
23
+ return _esbuild;
24
+ } catch {
25
+ _esbuild = false;
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Strip JSX/TSX syntax using esbuild, returning plain ESM JavaScript.
32
+ * Preserves line count as much as possible so line numbers stay accurate.
33
+ */
34
+ async function stripJsx(source, filePath) {
35
+ const esbuild = await getEsbuild();
36
+ if (!esbuild) return null; // esbuild not available
37
+
38
+ const ext = extname(filePath).slice(1); // 'jsx' | 'tsx'
39
+ const loader = ext === 'tsx' ? 'tsx' : 'jsx';
40
+ try {
41
+ const result = await esbuild.transform(source, {
42
+ loader,
43
+ format: 'esm',
44
+ jsx: 'automatic',
45
+ target: 'esnext',
46
+ sourcemap: false,
47
+ treeShaking: false,
48
+ minify: false,
49
+ minifyWhitespace: false,
50
+ minifySyntax: false,
51
+ });
52
+ return result.code;
53
+ } catch (err) {
54
+ if (config.debug) console.error('[trickle/esm] esbuild JSX transform failed:', err.message);
55
+ return null;
56
+ }
57
+ }
15
58
 
16
59
  let config = {
17
60
  wrapperPath: '',
@@ -352,7 +395,46 @@ function extractDestructuredNamesESM(pattern) {
352
395
  return names;
353
396
  }
354
397
 
355
- function transformSource(source, url) {
398
+ /**
399
+ * Map a transformed line number to the original source file line.
400
+ * Searches within ±80 lines for a `const/let/var <varName>` pattern.
401
+ */
402
+ function findOriginalLineESM(origLines, varName, transformedLine) {
403
+ const pattern = new RegExp(`\\b(const|let|var)\\s+${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
404
+ for (let delta = 0; delta <= 80; delta++) {
405
+ const fwd = transformedLine - 1 + delta;
406
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) return fwd + 1;
407
+ if (delta > 0 && delta <= 10) {
408
+ const bwd = transformedLine - 1 - delta;
409
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) return bwd + 1;
410
+ }
411
+ }
412
+ return -1;
413
+ }
414
+
415
+ /**
416
+ * Map a transformed destructured declaration line to the original source.
417
+ */
418
+ function findOriginalLineDestructuredESM(origLines, varNames, transformedLine) {
419
+ const namePatterns = varNames.map(n => new RegExp(`\\b${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b(?!\\s*:)`));
420
+ for (let delta = 0; delta <= 80; delta++) {
421
+ const fwd = transformedLine - 1 + delta;
422
+ if (fwd >= 0 && fwd < origLines.length) {
423
+ const line = origLines[fwd];
424
+ if (/\b(const|let|var)\s+[\[{]/.test(line) && namePatterns.some(p => p.test(line))) return fwd + 1;
425
+ }
426
+ if (delta > 0 && delta <= 10) {
427
+ const bwd = transformedLine - 1 - delta;
428
+ if (bwd >= 0 && bwd < origLines.length) {
429
+ const line = origLines[bwd];
430
+ if (/\b(const|let|var)\s+[\[{]/.test(line) && namePatterns.some(p => p.test(line))) return bwd + 1;
431
+ }
432
+ }
433
+ }
434
+ return -1;
435
+ }
436
+
437
+ function transformSource(source, url, originalSource) {
356
438
  const moduleName = moduleNameFromUrl(url);
357
439
  let filePath = url;
358
440
  try { filePath = fileURLToPath(url); } catch {}
@@ -368,6 +450,21 @@ function transformSource(source, url) {
368
450
  const varDecls = varTraceEnabled ? findVarDeclarationsESM(source) : [];
369
451
  const destructDecls = varTraceEnabled ? findDestructuredDeclarationsESM(source) : [];
370
452
 
453
+ // Map transformed line numbers to original source line numbers (if original source differs)
454
+ if (originalSource && originalSource !== source) {
455
+ const origLines = originalSource.split('\n');
456
+ for (const vi of varDecls) {
457
+ const orig = findOriginalLineESM(origLines, vi.varName, vi.lineNo);
458
+ if (orig !== -1) vi.lineNo = orig;
459
+ }
460
+ for (const di of destructDecls) {
461
+ if (di.varNames.length > 0) {
462
+ const orig = findOriginalLineDestructuredESM(origLines, di.varNames, di.lineNo);
463
+ if (orig !== -1) di.lineNo = orig;
464
+ }
465
+ }
466
+ }
467
+
371
468
  // Build a map: line number → trace calls to insert AFTER that line
372
469
  const traceAfterLine = new Map();
373
470
  for (const { varName, lineNo, insertAfterLine } of varDecls) {
@@ -563,6 +660,34 @@ function transformSource(source, url) {
563
660
  * ESM load hook — intercepts module loading to transform user modules.
564
661
  */
565
662
  export async function load(url, context, nextLoad) {
663
+ // Handle JSX/TSX files ourselves — Node.js cannot parse JSX natively.
664
+ // We read the file, strip JSX with esbuild, apply trickle instrumentation,
665
+ // and return the result without calling nextLoad (which would fail for JSX).
666
+ if (shouldObserve(url) && /\.(jsx|tsx)$/.test(url)) {
667
+ let filePath;
668
+ try { filePath = fileURLToPath(url); } catch { filePath = null; }
669
+
670
+ if (filePath) {
671
+ try {
672
+ const rawSource = readFileSync(filePath, 'utf-8');
673
+ const jsSource = await stripJsx(rawSource, filePath);
674
+ if (jsSource !== null) {
675
+ const varTraceEnabled = process.env.TRICKLE_TRACE_VARS !== '0' && config.traceVarPath;
676
+ const hasVarDecls = varTraceEnabled && /^[ \t]*(?:export\s+)?(?:const|let|var)\s+[a-zA-Z_$]/m.test(jsSource);
677
+ if (jsSource.includes('export ') || hasVarDecls) {
678
+ // Pass rawSource as originalSource so line numbers map to the .jsx/.tsx file
679
+ const transformed = transformSource(jsSource, url, rawSource);
680
+ return { source: transformed, format: 'module', shortCircuit: true };
681
+ }
682
+ // No observable exports/vars — return stripped JS so Node can execute it
683
+ return { source: jsSource, format: 'module', shortCircuit: true };
684
+ }
685
+ } catch (err) {
686
+ if (config.debug) console.error(`[trickle/esm] JSX load failed for ${url}:`, err.message);
687
+ }
688
+ }
689
+ }
690
+
566
691
  const result = await nextLoad(url, context);
567
692
 
568
693
  // Only transform ESM modules we should observe
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,14 +12,19 @@
12
12
  "./auto": "./auto.js",
13
13
  "./auto-env": "./auto-env.js",
14
14
  "./auto-esm": "./auto-esm.mjs",
15
- "./vite-plugin": "./dist/vite-plugin.js",
15
+ "./vite-plugin": {
16
+ "import": "./dist-esm/vite-plugin.js",
17
+ "require": "./dist/vite-plugin.js"
18
+ },
16
19
  "./trace-var": "./dist/trace-var.js"
17
20
  },
18
21
  "scripts": {
19
- "build": "tsc",
22
+ "build": "tsc && tsc -p tsconfig.esm.json",
20
23
  "prepublishOnly": "npm run build"
21
24
  },
22
- "dependencies": {},
25
+ "optionalDependencies": {
26
+ "esbuild": "^0.27.4"
27
+ },
23
28
  "devDependencies": {
24
29
  "typescript": "^5.4.0"
25
30
  },
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist-esm",
5
+ "rootDir": "./src",
6
+ "module": "es2022",
7
+ "moduleResolution": "bundler",
8
+ "declaration": false
9
+ },
10
+ "include": ["src/vite-plugin.ts"]
11
+ }