trickle-observe 0.2.1 → 0.2.3

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.
@@ -11,7 +11,7 @@
11
11
  */
12
12
  export declare function injectTypes(): number;
13
13
  /**
14
- * Read observations and generate .d.ts files next to source files.
14
+ * Read observations and generate .d.ts type files in .trickle/types/.
15
15
  * Returns the number of functions typed.
16
16
  */
17
17
  export declare function generateTypes(): number;
@@ -248,6 +248,17 @@ function typeToTS(node, ext, parent, prop, indent) {
248
248
  const keys = Object.keys(node.properties || {});
249
249
  if (keys.length === 0)
250
250
  return 'Record<string, never>';
251
+ // Recognize special marker objects from type-inference
252
+ if (keys.length === 1 && keys[0] === '__date')
253
+ return 'Date';
254
+ if (keys.length === 1 && keys[0] === '__buffer')
255
+ return 'Buffer';
256
+ if (keys.length === 1 && keys[0] === '__typedArray')
257
+ return 'TypedArray';
258
+ if (keys.includes('__regexp'))
259
+ return 'RegExp';
260
+ if (keys.includes('__error'))
261
+ return 'Error';
251
262
  if (keys.length > 2 && prop) {
252
263
  const iName = toPascalCase(parent) + toPascalCase(prop);
253
264
  if (!ext.some(e => e.name === iName))
@@ -543,6 +554,15 @@ function typeToJSDoc(node) {
543
554
  const keys = Object.keys(props);
544
555
  if (keys.length === 0)
545
556
  return 'Object';
557
+ // Recognize special marker objects from type-inference
558
+ if (keys.length === 1 && keys[0] === '__date')
559
+ return 'Date';
560
+ if (keys.length === 1 && keys[0] === '__buffer')
561
+ return 'Buffer';
562
+ if (keys.includes('__regexp'))
563
+ return 'RegExp';
564
+ if (keys.includes('__error'))
565
+ return 'Error';
546
566
  const entries = keys.map(k => {
547
567
  const { isOptional, innerType } = extractOptional(props[k]);
548
568
  return isOptional ? `${k}?: ${typeToJSDoc(innerType)}` : `${k}: ${typeToJSDoc(innerType)}`;
@@ -673,9 +693,143 @@ function injectTypes() {
673
693
  }
674
694
  // ── Public API ──
675
695
  let lastSize = 0;
676
- let lastContent = '';
696
+ const lastContentByModule = new Map();
697
+ let tsconfigPatched = false;
677
698
  /**
678
- * Read observations and generate .d.ts files next to source files.
699
+ * Generate a typed Express route definitions file from route observations.
700
+ * Each route (e.g., "GET /users") becomes a typed interface.
701
+ */
702
+ function generateRoutesDts(routeFunctions) {
703
+ const sections = [
704
+ '// Auto-generated by trickle — Express route types from runtime observations',
705
+ `// Generated at ${new Date().toISOString()}`,
706
+ '// Do not edit — types update automatically as your code runs',
707
+ '',
708
+ ];
709
+ const ext = [];
710
+ for (const fn of routeFunctions) {
711
+ // Route name like "GET /users" → GetUsers
712
+ const routeName = fn.name;
713
+ const baseName = toPascalCase(routeName);
714
+ // Input type (body, params, query)
715
+ const inputProps = fn.argsType.kind === 'object' ? fn.argsType.properties || {} : {};
716
+ const hasInput = Object.keys(inputProps).length > 0;
717
+ if (hasInput) {
718
+ sections.push(renderInterface(`${baseName}Input`, fn.argsType, ext));
719
+ sections.push('');
720
+ }
721
+ // Output type
722
+ if (fn.returnType.kind === 'object' && Object.keys(fn.returnType.properties || {}).length > 0) {
723
+ sections.push(renderInterface(`${baseName}Output`, fn.returnType, ext));
724
+ }
725
+ else {
726
+ sections.push(`export type ${baseName}Output = ${typeToTS(fn.returnType, ext, baseName, undefined, 0)};`);
727
+ }
728
+ sections.push('');
729
+ }
730
+ // Emit extracted interfaces
731
+ const emitted = new Set();
732
+ const extLines = [];
733
+ for (const iface of ext) {
734
+ if (emitted.has(iface.name))
735
+ continue;
736
+ emitted.add(iface.name);
737
+ extLines.push(renderInterface(iface.name, iface.node, ext));
738
+ extLines.push('');
739
+ }
740
+ if (extLines.length > 0) {
741
+ // Insert extracted interfaces after the header, before route types
742
+ sections.splice(4, 0, ...extLines);
743
+ }
744
+ // Route map type for typed middleware/client usage
745
+ sections.push('/** All observed routes with their input/output types */');
746
+ sections.push('export interface TrickleRoutes {');
747
+ for (const fn of routeFunctions) {
748
+ const baseName = toPascalCase(fn.name);
749
+ const inputProps = fn.argsType.kind === 'object' ? fn.argsType.properties || {} : {};
750
+ const hasInput = Object.keys(inputProps).length > 0;
751
+ const inputType = hasInput ? `${baseName}Input` : 'Record<string, never>';
752
+ sections.push(` '${fn.name}': { input: ${inputType}; output: ${baseName}Output };`);
753
+ }
754
+ sections.push('}');
755
+ sections.push('');
756
+ return sections.join('\n');
757
+ }
758
+ /**
759
+ * Ensure .trickle/types/ directory exists.
760
+ */
761
+ function ensureTypesDir(trickleDir) {
762
+ const typesDir = path.join(trickleDir, 'types');
763
+ try {
764
+ fs.mkdirSync(typesDir, { recursive: true });
765
+ }
766
+ catch { /* already exists */ }
767
+ return typesDir;
768
+ }
769
+ /**
770
+ * Auto-patch tsconfig.json to include .trickle/types so generated
771
+ * types are visible in VSCode and tsc. Only runs once per process.
772
+ */
773
+ function patchTsConfig() {
774
+ if (tsconfigPatched)
775
+ return;
776
+ tsconfigPatched = true;
777
+ const tsconfigPath = path.join(process.cwd(), 'tsconfig.json');
778
+ try {
779
+ if (!fs.existsSync(tsconfigPath))
780
+ return;
781
+ const raw = fs.readFileSync(tsconfigPath, 'utf-8');
782
+ // Strip JSON comments safely (skip strings to avoid breaking paths like "@/*")
783
+ let stripped = '';
784
+ let i = 0;
785
+ while (i < raw.length) {
786
+ if (raw[i] === '"') {
787
+ // Skip string content
788
+ let j = i + 1;
789
+ while (j < raw.length && raw[j] !== '"') {
790
+ if (raw[j] === '\\')
791
+ j++; // skip escaped char
792
+ j++;
793
+ }
794
+ stripped += raw.slice(i, j + 1);
795
+ i = j + 1;
796
+ }
797
+ else if (raw[i] === '/' && i + 1 < raw.length && raw[i + 1] === '/') {
798
+ // Line comment — skip to end of line
799
+ while (i < raw.length && raw[i] !== '\n')
800
+ i++;
801
+ }
802
+ else if (raw[i] === '/' && i + 1 < raw.length && raw[i + 1] === '*') {
803
+ // Block comment — skip to */
804
+ i += 2;
805
+ while (i < raw.length - 1 && !(raw[i] === '*' && raw[i + 1] === '/'))
806
+ i++;
807
+ i += 2;
808
+ }
809
+ else {
810
+ stripped += raw[i];
811
+ i++;
812
+ }
813
+ }
814
+ // Also strip trailing commas (common in tsconfig)
815
+ const cleaned = stripped.replace(/,(\s*[}\]])/g, '$1');
816
+ const config = JSON.parse(cleaned);
817
+ const include = config.include;
818
+ if (include && include.some((p) => p === '.trickle' || p.startsWith('.trickle/'))) {
819
+ return; // Already configured
820
+ }
821
+ if (include) {
822
+ include.push('.trickle/types');
823
+ }
824
+ else {
825
+ config.include = ['.trickle/types'];
826
+ }
827
+ fs.writeFileSync(tsconfigPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
828
+ }
829
+ catch { /* don't crash — tsconfig patching is best-effort */ }
830
+ }
831
+ /**
832
+ * Read observations and generate .d.ts type files in .trickle/types/.
679
833
  * Returns the number of functions typed.
680
834
  */
681
835
  function generateTypes() {
@@ -696,35 +850,53 @@ function generateTypes() {
696
850
  const functions = readAndMerge(jsonlPath);
697
851
  if (functions.length === 0)
698
852
  return 0;
699
- // Group by module and generate .d.ts next to source files
700
- const byModule = new Map();
853
+ // Separate Express route observations from regular functions
854
+ const routeFunctions = [];
855
+ const regularFunctions = [];
701
856
  for (const fn of functions) {
857
+ if (fn.module === 'express' && /^(GET|POST|PUT|DELETE|PATCH|ALL)\s/.test(fn.name)) {
858
+ routeFunctions.push(fn);
859
+ }
860
+ else {
861
+ regularFunctions.push(fn);
862
+ }
863
+ }
864
+ // Group regular functions by module
865
+ const byModule = new Map();
866
+ for (const fn of regularFunctions) {
702
867
  const mod = fn.module || '_default';
703
868
  if (!byModule.has(mod))
704
869
  byModule.set(mod, []);
705
870
  byModule.get(mod).push(fn);
706
871
  }
872
+ // Write all types to .trickle/types/ so TypeScript picks them up via tsconfig include
873
+ const typesDir = ensureTypesDir(trickleDir);
874
+ patchTsConfig();
707
875
  let totalFunctions = 0;
876
+ // Generate Express route types
877
+ if (routeFunctions.length > 0) {
878
+ const routesDts = generateRoutesDts(routeFunctions);
879
+ if (routesDts !== lastContentByModule.get('__routes')) {
880
+ const routesDtsPath = path.join(typesDir, 'routes.d.ts');
881
+ try {
882
+ fs.writeFileSync(routesDtsPath, routesDts, 'utf-8');
883
+ lastContentByModule.set('__routes', routesDts);
884
+ totalFunctions += routeFunctions.length;
885
+ }
886
+ catch { /* don't crash */ }
887
+ }
888
+ }
708
889
  for (const [mod, fns] of byModule) {
709
890
  // Skip HTTP route observations (module is hostname like "localhost")
710
891
  if (mod.includes('.') && !mod.includes('/') && !mod.includes('\\'))
711
892
  continue;
712
893
  const dts = generateDts(fns);
713
- if (dts === lastContent)
894
+ if (dts === lastContentByModule.get(mod))
714
895
  continue;
715
- // Find source file for this module
716
- const sourceFile = findSourceFile(mod);
717
- if (!sourceFile)
718
- continue;
719
- const ext = path.extname(sourceFile);
720
- const dir = path.dirname(sourceFile);
721
- const baseName = path.basename(sourceFile, ext);
722
- // For .ts/.tsx files, use .trickle.d.ts to avoid conflicts (TS ignores .d.ts next to .ts)
723
- const isTs = ext === '.ts' || ext === '.tsx';
724
- const dtsPath = path.join(dir, `${baseName}${isTs ? '.trickle' : ''}.d.ts`);
896
+ const dtsPath = path.join(typesDir, `${mod}.d.ts`);
725
897
  try {
726
898
  fs.writeFileSync(dtsPath, dts, 'utf-8');
727
- lastContent = dts;
899
+ lastContentByModule.set(mod, dts);
728
900
  totalFunctions += fns.length;
729
901
  }
730
902
  catch { /* don't crash user's app */ }
@@ -858,6 +1030,14 @@ function typeToCompact(node, depth = 0) {
858
1030
  const keys = Object.keys(props);
859
1031
  if (keys.length === 0)
860
1032
  return '{}';
1033
+ if (keys.length === 1 && keys[0] === '__date')
1034
+ return 'Date';
1035
+ if (keys.length === 1 && keys[0] === '__buffer')
1036
+ return 'Buffer';
1037
+ if (keys.includes('__regexp'))
1038
+ return 'RegExp';
1039
+ if (keys.includes('__error'))
1040
+ return 'Error';
861
1041
  if (keys.length > 4) {
862
1042
  const shown = keys.slice(0, 3).map(k => `${k}: ${typeToCompact(props[k], depth + 1)}`);
863
1043
  return `{ ${shown.join(', ')}, ... }`;
@@ -970,6 +1150,39 @@ function generateTypeSummary() {
970
1150
  }
971
1151
  return lines.join('\n');
972
1152
  }
1153
+ /**
1154
+ * Cached recursive file index: maps basename (without ext) → full path.
1155
+ * Built once on first call, then reused.
1156
+ */
1157
+ let fileIndex = null;
1158
+ function buildFileIndex() {
1159
+ const cwd = process.cwd();
1160
+ const exts = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs']);
1161
+ const index = new Map();
1162
+ // Common source directories to scan
1163
+ const dirs = ['src', 'dist', 'lib', 'app', '.'];
1164
+ for (const dir of dirs) {
1165
+ const absDir = path.join(cwd, dir);
1166
+ try {
1167
+ const entries = fs.readdirSync(absDir, { recursive: true, withFileTypes: true });
1168
+ for (const entry of entries) {
1169
+ if (!entry.isFile())
1170
+ continue;
1171
+ const ext = path.extname(entry.name);
1172
+ if (!exts.has(ext))
1173
+ continue;
1174
+ const baseName = entry.name.replace(/\.[^.]+$/, '');
1175
+ const fullPath = path.join(absDir, entry.parentPath ? path.relative(absDir, entry.parentPath) : '', entry.name);
1176
+ // Prefer src/ over dist/ for the same basename
1177
+ if (!index.has(baseName) || fullPath.includes('/src/')) {
1178
+ index.set(baseName, fullPath);
1179
+ }
1180
+ }
1181
+ }
1182
+ catch { /* dir doesn't exist */ }
1183
+ }
1184
+ return index;
1185
+ }
973
1186
  /**
974
1187
  * Try to find the source file for a given module name.
975
1188
  */
@@ -995,5 +1208,9 @@ function findSourceFile(moduleName) {
995
1208
  if (fs.existsSync(candidate))
996
1209
  return candidate;
997
1210
  }
998
- return null;
1211
+ // Recursive search using cached file index
1212
+ if (!fileIndex) {
1213
+ fileIndex = buildFileIndex();
1214
+ }
1215
+ return fileIndex.get(moduleName) || null;
999
1216
  }
@@ -44,7 +44,7 @@ function runGeneration(isFinal) {
44
44
  lastFunctionCount = count;
45
45
  }
46
46
  if (isFinal && lastFunctionCount > 0) {
47
- console.log(`[trickle/auto] ${lastFunctionCount} function type(s) written to .d.ts`);
47
+ console.log(`[trickle/auto] ${lastFunctionCount} function type(s) written to .trickle/types/`);
48
48
  // Inject JSDoc into source files if TRICKLE_INJECT=1
49
49
  try {
50
50
  const injected = (0, auto_codegen_1.injectTypes)();
package/dist/express.js CHANGED
@@ -282,6 +282,7 @@ function trickleMiddleware(userOpts) {
282
282
  sampleRate: userOpts?.sampleRate ?? 1,
283
283
  maxDepth: userOpts?.maxDepth ?? 5,
284
284
  };
285
+ const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
285
286
  return function trickleMiddlewareHandler(req, res, next) {
286
287
  if (!opts.enabled) {
287
288
  next();
@@ -292,6 +293,9 @@ function trickleMiddleware(userOpts) {
292
293
  next();
293
294
  return;
294
295
  }
296
+ if (debug) {
297
+ console.log(`[trickle/middleware] Intercepting ${req.method} ${req.originalUrl || req.url}`);
298
+ }
295
299
  let captured = false;
296
300
  // We derive the route name lazily once the response is being sent,
297
301
  // because req.route is only populated after the handler matches.
@@ -314,6 +318,9 @@ function trickleMiddleware(userOpts) {
314
318
  if (!captured) {
315
319
  captured = true;
316
320
  const routeName = getRouteName();
321
+ if (debug) {
322
+ console.log(`[trickle/middleware] Captured res.json for ${routeName}`);
323
+ }
317
324
  // Re-extract input here because body parsers may have run since middleware was entered
318
325
  const latestInput = extractRequestInput(req);
319
326
  emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data);
@@ -322,6 +329,9 @@ function trickleMiddleware(userOpts) {
322
329
  return originalJson.call(res, data);
323
330
  };
324
331
  }
332
+ else if (debug) {
333
+ console.log(`[trickle/middleware] res.json is not a function: ${typeof originalJson}`);
334
+ }
325
335
  // Intercept res.send()
326
336
  const originalSend = res.send;
327
337
  if (typeof originalSend === 'function') {
@@ -329,6 +339,9 @@ function trickleMiddleware(userOpts) {
329
339
  if (!captured) {
330
340
  captured = true;
331
341
  const routeName = getRouteName();
342
+ if (debug) {
343
+ console.log(`[trickle/middleware] Captured res.send for ${routeName}`);
344
+ }
332
345
  const latestInput = extractRequestInput(req);
333
346
  const output = typeof data === 'string' ? { __html: true } : data;
334
347
  emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output);