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.
- package/dist/auto-codegen.d.ts +1 -1
- package/dist/auto-codegen.js +234 -17
- package/dist/auto-register.js +1 -1
- package/dist/express.js +13 -0
- package/dist/observe-register.js +450 -41
- package/dist/trace-var.d.ts +44 -0
- package/dist/trace-var.js +219 -0
- package/dist/transport.js +5 -1
- package/dist/type-inference.js +9 -1
- package/dist/vite-plugin.d.ts +5 -2
- package/dist/vite-plugin.js +385 -25
- package/dist/wrap.js +4 -12
- package/package.json +10 -3
- package/src/auto-codegen.ts +226 -18
- package/src/auto-register.ts +1 -1
- package/src/express.d.ts +387 -0
- package/src/express.ts +14 -0
- package/src/observe-register.ts +420 -41
- package/src/trace-var.ts +202 -0
- package/src/transport.ts +4 -1
- package/src/type-inference.ts +11 -1
- package/src/vite-plugin.ts +444 -24
- package/src/wrap.ts +4 -12
package/dist/auto-codegen.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function injectTypes(): number;
|
|
13
13
|
/**
|
|
14
|
-
* Read observations and generate .d.ts 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;
|
package/dist/auto-codegen.js
CHANGED
|
@@ -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
|
-
|
|
696
|
+
const lastContentByModule = new Map();
|
|
697
|
+
let tsconfigPatched = false;
|
|
677
698
|
/**
|
|
678
|
-
*
|
|
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
|
-
//
|
|
700
|
-
const
|
|
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 ===
|
|
894
|
+
if (dts === lastContentByModule.get(mod))
|
|
714
895
|
continue;
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1211
|
+
// Recursive search using cached file index
|
|
1212
|
+
if (!fileIndex) {
|
|
1213
|
+
fileIndex = buildFileIndex();
|
|
1214
|
+
}
|
|
1215
|
+
return fileIndex.get(moduleName) || null;
|
|
999
1216
|
}
|
package/dist/auto-register.js
CHANGED
|
@@ -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 .
|
|
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);
|