trickle-observe 0.1.0

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.
Files changed (57) hide show
  1. package/auto-env.js +13 -0
  2. package/auto-esm.mjs +128 -0
  3. package/auto.js +3 -0
  4. package/dist/auto-codegen.d.ts +29 -0
  5. package/dist/auto-codegen.js +999 -0
  6. package/dist/auto-register.d.ts +16 -0
  7. package/dist/auto-register.js +99 -0
  8. package/dist/cache.d.ts +27 -0
  9. package/dist/cache.js +52 -0
  10. package/dist/env-detect.d.ts +5 -0
  11. package/dist/env-detect.js +35 -0
  12. package/dist/express.d.ts +44 -0
  13. package/dist/express.js +342 -0
  14. package/dist/fetch-observer.d.ts +24 -0
  15. package/dist/fetch-observer.js +217 -0
  16. package/dist/index.d.ts +64 -0
  17. package/dist/index.js +172 -0
  18. package/dist/observe-register.d.ts +29 -0
  19. package/dist/observe-register.js +455 -0
  20. package/dist/observe.d.ts +44 -0
  21. package/dist/observe.js +109 -0
  22. package/dist/proxy-tracker.d.ts +15 -0
  23. package/dist/proxy-tracker.js +172 -0
  24. package/dist/register.d.ts +21 -0
  25. package/dist/register.js +105 -0
  26. package/dist/transport.d.ts +22 -0
  27. package/dist/transport.js +228 -0
  28. package/dist/type-hash.d.ts +5 -0
  29. package/dist/type-hash.js +60 -0
  30. package/dist/type-inference.d.ts +14 -0
  31. package/dist/type-inference.js +259 -0
  32. package/dist/types.d.ts +78 -0
  33. package/dist/types.js +2 -0
  34. package/dist/wrap.d.ts +10 -0
  35. package/dist/wrap.js +247 -0
  36. package/observe-esm-hooks.mjs +367 -0
  37. package/observe-esm.mjs +40 -0
  38. package/observe.js +2 -0
  39. package/package.json +26 -0
  40. package/register.js +2 -0
  41. package/src/auto-codegen.ts +1058 -0
  42. package/src/auto-register.ts +102 -0
  43. package/src/cache.ts +53 -0
  44. package/src/env-detect.ts +22 -0
  45. package/src/express.ts +386 -0
  46. package/src/fetch-observer.ts +226 -0
  47. package/src/index.ts +199 -0
  48. package/src/observe-register.ts +453 -0
  49. package/src/observe.ts +127 -0
  50. package/src/proxy-tracker.ts +208 -0
  51. package/src/register.ts +110 -0
  52. package/src/transport.ts +207 -0
  53. package/src/type-hash.ts +71 -0
  54. package/src/type-inference.ts +285 -0
  55. package/src/types.ts +61 -0
  56. package/src/wrap.ts +289 -0
  57. package/tsconfig.json +8 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * ESM loader hooks for trickle observation.
3
+ *
4
+ * Transforms user ESM modules to wrap exported functions with trickle
5
+ * observation. Runs in a separate loader thread (Node.js >= 20.6).
6
+ *
7
+ * Strategy: For each user module, the `load` hook:
8
+ * 1. Strips `export` from function/const declarations
9
+ * 2. Appends wrapper code that wraps each exported function
10
+ * 3. Re-exports the wrapped versions
11
+ */
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { basename, sep } from 'node:path';
15
+
16
+ let config = {
17
+ wrapperPath: '',
18
+ transportPath: '',
19
+ envDetectPath: '',
20
+ backendUrl: 'http://localhost:4888',
21
+ debug: false,
22
+ includePatterns: [],
23
+ excludePatterns: [],
24
+ initialized: false,
25
+ };
26
+
27
+ export function initialize(data) {
28
+ config = { ...config, ...data, initialized: true };
29
+
30
+ // Configure trickle transport from the loader thread
31
+ try {
32
+ const require = createRequire(import.meta.url);
33
+ const { configure } = require(config.transportPath);
34
+ const { detectEnvironment } = require(config.envDetectPath);
35
+ const environment = process.env.TRICKLE_ENV || detectEnvironment();
36
+
37
+ configure({
38
+ backendUrl: config.backendUrl,
39
+ batchIntervalMs: 2000,
40
+ debug: config.debug,
41
+ enabled: true,
42
+ environment,
43
+ });
44
+ } catch (err) {
45
+ if (config.debug) {
46
+ console.error('[trickle/esm] Failed to configure transport:', err.message);
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Determine if a URL should be observed.
53
+ */
54
+ function shouldObserve(url) {
55
+ // Only file:// URLs
56
+ if (!url.startsWith('file://')) return false;
57
+
58
+ let filePath;
59
+ try {
60
+ filePath = fileURLToPath(url);
61
+ } catch {
62
+ return false;
63
+ }
64
+
65
+ // Skip node_modules
66
+ if (filePath.includes(`${sep}node_modules${sep}`)) return false;
67
+
68
+ // Skip trickle's own modules
69
+ if (filePath.includes(`${sep}client-js${sep}`)) return false;
70
+ if (filePath.includes(`${sep}trickle${sep}dist${sep}`)) return false;
71
+
72
+ // Only JS/TS files
73
+ if (!/\.(m?js|jsx|ts|tsx)$/.test(filePath)) return false;
74
+
75
+ // Apply include filters
76
+ if (config.includePatterns.length > 0) {
77
+ if (!config.includePatterns.some(p => filePath.includes(p))) return false;
78
+ }
79
+
80
+ // Apply exclude filters
81
+ if (config.excludePatterns.length > 0) {
82
+ if (config.excludePatterns.some(p => filePath.includes(p))) return false;
83
+ }
84
+
85
+ return true;
86
+ }
87
+
88
+ /**
89
+ * Extract module name from a file URL.
90
+ */
91
+ function moduleNameFromUrl(url) {
92
+ try {
93
+ const filePath = fileURLToPath(url);
94
+ return basename(filePath).replace(/\.(m?js|jsx|ts|tsx)$/, '');
95
+ } catch {
96
+ return 'unknown';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Transform ESM source to wrap exported functions.
102
+ *
103
+ * Handles:
104
+ * - export function name(...) { ... }
105
+ * - export async function name(...) { ... }
106
+ * - export const name = (...) => ...
107
+ * - export const name = function(...) { ... }
108
+ * - export const name = async (...) => ...
109
+ * - export default function name(...) { ... }
110
+ * - export default function(...) { ... }
111
+ * - export { name1, name2 }
112
+ */
113
+ /**
114
+ * Extract parameter names from a function source snippet (everything after the function name).
115
+ */
116
+ function extractParamNamesFromSource(source, startIdx) {
117
+ // Find the opening paren
118
+ const openParen = source.indexOf('(', startIdx);
119
+ if (openParen === -1) return [];
120
+ // Find matching close paren (handles nested parens in TS type annotations)
121
+ let depth = 1;
122
+ let i = openParen + 1;
123
+ while (i < source.length && depth > 0) {
124
+ if (source[i] === '(') depth++;
125
+ else if (source[i] === ')') depth--;
126
+ i++;
127
+ }
128
+ if (depth !== 0) return [];
129
+ const closeParen = i - 1;
130
+ const paramStr = source.slice(openParen + 1, closeParen).trim();
131
+ if (!paramStr) return [];
132
+ // Split on top-level commas only (skip commas inside nested parens/generics)
133
+ const params = [];
134
+ let current = '';
135
+ let parenDepth = 0;
136
+ let angleDepth = 0;
137
+ for (let j = 0; j < paramStr.length; j++) {
138
+ const ch = paramStr[j];
139
+ if (ch === '(' || ch === '{' || ch === '[') parenDepth++;
140
+ else if (ch === ')' || ch === '}' || ch === ']') parenDepth--;
141
+ else if (ch === '<') angleDepth++;
142
+ else if (ch === '>' && paramStr[j - 1] !== '=') {
143
+ // Don't count '>' in '=>' (arrow functions) as closing a generic
144
+ if (angleDepth > 0) angleDepth--;
145
+ }
146
+ else if (ch === ',' && parenDepth === 0 && angleDepth === 0) {
147
+ params.push(current);
148
+ current = '';
149
+ continue;
150
+ }
151
+ current += ch;
152
+ }
153
+ if (current.trim()) params.push(current);
154
+ return params.map(p => {
155
+ // Strip TS type annotation and default value: "name: Type = default" → "name"
156
+ const trimmed = p.trim().split(':')[0].trim().split('=')[0].trim();
157
+ if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('...')) return '';
158
+ return trimmed;
159
+ }).filter(Boolean);
160
+ }
161
+
162
+ /**
163
+ * Compute the byte offset of a given line index within the full source.
164
+ */
165
+ function lineOffset(source, lineIdx) {
166
+ let off = 0;
167
+ const lines = source.split('\n');
168
+ for (let i = 0; i < lineIdx && i < lines.length; i++) {
169
+ off += lines[i].length + 1; // +1 for \n
170
+ }
171
+ return off;
172
+ }
173
+
174
+ function transformSource(source, url) {
175
+ const moduleName = moduleNameFromUrl(url);
176
+ const lines = source.split('\n');
177
+ const exportedFunctions = []; // { name, paramNames }
178
+ const exportedDefaults = []; // { name, paramNames }
179
+ const namedExports = []; // from `export { name }` statements
180
+ const result = [];
181
+
182
+ for (let i = 0; i < lines.length; i++) {
183
+ const line = lines[i];
184
+ const trimmed = line.trimStart();
185
+
186
+ // Skip TS-only exports: export interface, export type, export enum
187
+ if (/^export\s+(interface|type|enum|abstract|declare)\s/.test(trimmed)) {
188
+ result.push(line);
189
+ continue;
190
+ }
191
+
192
+ // Skip re-exports: export { ... } from '...' or export * from '...'
193
+ if (/^export\s+\{[^}]*\}\s+from\s/.test(trimmed) || /^export\s+\*\s+from\s/.test(trimmed)) {
194
+ result.push(line);
195
+ continue;
196
+ }
197
+
198
+ // export function name(...) or export function name<T>(...)
199
+ const funcMatch = trimmed.match(/^export\s+(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(/);
200
+ if (funcMatch) {
201
+ const name = funcMatch[2];
202
+ // Use full source from this line's offset for multiline param extraction
203
+ const srcOffset = lineOffset(source, i) + (line.length - trimmed.length);
204
+ const parenPos = source.indexOf('(', srcOffset + funcMatch[0].indexOf('('));
205
+ const paramNames = parenPos >= 0 ? extractParamNamesFromSource(source, parenPos) : [];
206
+ // Remove 'export ' prefix, keep the function
207
+ result.push(line.replace(/^(\s*)export\s+/, '$1'));
208
+ exportedFunctions.push({ name, paramNames });
209
+ continue;
210
+ }
211
+
212
+ // export const name = (...) => or export const name = function or export const name = async
213
+ const constFuncMatch = trimmed.match(/^export\s+(const|let)\s+(\w+)\s*=\s*(async\s+)?(\(|function\b)/);
214
+ if (constFuncMatch) {
215
+ const name = constFuncMatch[2];
216
+ const srcOffset = lineOffset(source, i) + (line.length - trimmed.length);
217
+ const eqPos = source.indexOf('=', srcOffset);
218
+ const parenPos = eqPos >= 0 ? source.indexOf('(', eqPos) : -1;
219
+ const paramNames = parenPos >= 0 ? extractParamNamesFromSource(source, parenPos) : [];
220
+ result.push(line.replace(/^(\s*)export\s+/, '$1'));
221
+ exportedFunctions.push({ name, paramNames });
222
+ continue;
223
+ }
224
+
225
+ // export const name = someValue (non-function — keep as-is)
226
+ const constNonFuncMatch = trimmed.match(/^export\s+(const|let|var)\s+(\w+)\s*=/);
227
+ if (constNonFuncMatch && !constFuncMatch) {
228
+ // Keep the export as-is for non-function values
229
+ result.push(line);
230
+ continue;
231
+ }
232
+
233
+ // export default function name(...) or export default function name<T>(...)
234
+ const defaultNamedMatch = trimmed.match(/^export\s+default\s+(async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(/);
235
+ if (defaultNamedMatch) {
236
+ const name = defaultNamedMatch[2];
237
+ const srcOffset = lineOffset(source, i) + (line.length - trimmed.length);
238
+ const parenPos = source.indexOf('(', srcOffset + defaultNamedMatch[0].indexOf('('));
239
+ const paramNames = parenPos >= 0 ? extractParamNamesFromSource(source, parenPos) : [];
240
+ // Remove 'export default'
241
+ result.push(line.replace(/^(\s*)export\s+default\s+/, '$1'));
242
+ exportedDefaults.push({ name, paramNames });
243
+ continue;
244
+ }
245
+
246
+ // export default function(...) (anonymous)
247
+ const defaultAnonMatch = trimmed.match(/^export\s+default\s+(async\s+)?function\s*\(/);
248
+ if (defaultAnonMatch) {
249
+ const srcOffset = lineOffset(source, i) + (line.length - trimmed.length);
250
+ const parenPos = source.indexOf('(', srcOffset);
251
+ const paramNames = parenPos >= 0 ? extractParamNamesFromSource(source, parenPos) : [];
252
+ // Convert to named: const __trickle_default = function(...)
253
+ result.push(line.replace(
254
+ /^(\s*)export\s+default\s+(async\s+)?function\s*\(/,
255
+ '$1const __trickle_default = $2function('
256
+ ));
257
+ exportedDefaults.push({ name: '__trickle_default', paramNames });
258
+ continue;
259
+ }
260
+
261
+ // export { name1, name2 } (local named exports, not re-exports)
262
+ const namedExportMatch = trimmed.match(/^export\s+\{([^}]+)\}\s*;?\s*$/);
263
+ if (namedExportMatch) {
264
+ const names = namedExportMatch[1].split(',').map(s => s.trim()).filter(Boolean);
265
+ for (const nameSpec of names) {
266
+ // Handle: name or name as alias
267
+ const parts = nameSpec.split(/\s+as\s+/);
268
+ namedExports.push({ local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() });
269
+ }
270
+ // Remove this export statement — we'll re-export at the bottom
271
+ result.push('// [trickle] moved export to bottom');
272
+ continue;
273
+ }
274
+
275
+ // export class, export type, export interface — leave as-is
276
+ result.push(line);
277
+ }
278
+
279
+ // If nothing to wrap, return original
280
+ if (exportedFunctions.length === 0 && exportedDefaults.length === 0 && namedExports.length === 0) {
281
+ return source;
282
+ }
283
+
284
+ // Add wrapper import and wrapping code at the bottom
285
+ const wrapperPathEscaped = config.wrapperPath.replace(/\\/g, '\\\\');
286
+
287
+ result.push('');
288
+ result.push('// [trickle] Auto-observation wrappers');
289
+ result.push(`import { createRequire as __cr } from 'node:module';`);
290
+ result.push(`const __require = __cr(import.meta.url);`);
291
+ result.push(`const { wrapFunction: __tw } = __require('${wrapperPathEscaped}');`);
292
+ result.push(`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; };`);
293
+
294
+ // Wrap and re-export named functions
295
+ const reExports = [];
296
+ for (const { name, paramNames } of exportedFunctions) {
297
+ const wrappedName = `__trickle_${name}`;
298
+ const pn = paramNames.length > 0 ? JSON.stringify(paramNames) : 'null';
299
+ result.push(`const ${wrappedName} = typeof ${name} === 'function' ? __tw(${name}, __twOpts('${name}', ${pn})) : ${name};`);
300
+ reExports.push(`${wrappedName} as ${name}`);
301
+ }
302
+
303
+ // Handle named exports from export { } statements
304
+ for (const { local, exported } of namedExports) {
305
+ const wrappedName = `__trickle_ne_${exported}`;
306
+ result.push(`const ${wrappedName} = typeof ${local} === 'function' ? __tw(${local}, __twOpts('${exported}', null)) : ${local};`);
307
+ reExports.push(`${wrappedName} as ${exported}`);
308
+ }
309
+
310
+ if (reExports.length > 0) {
311
+ result.push(`export { ${reExports.join(', ')} };`);
312
+ }
313
+
314
+ // Handle default exports
315
+ for (const { name, paramNames } of exportedDefaults) {
316
+ const displayName = name === '__trickle_default' ? 'default' : name;
317
+ const pn = paramNames.length > 0 ? JSON.stringify(paramNames) : 'null';
318
+ result.push(`const __trickle_default_wrapped = typeof ${name} === 'function' ? __tw(${name}, __twOpts('${displayName}', ${pn})) : ${name};`);
319
+ result.push(`export default __trickle_default_wrapped;`);
320
+ }
321
+
322
+ const transformed = result.join('\n');
323
+
324
+ if (config.debug) {
325
+ const fnCount = exportedFunctions.length + exportedDefaults.length + namedExports.length;
326
+ console.log(`[trickle/esm] Transformed ${fnCount} exports from ${moduleName} (${fileURLToPath(url)})`);
327
+ }
328
+
329
+ return transformed;
330
+ }
331
+
332
+ /**
333
+ * ESM load hook — intercepts module loading to transform user modules.
334
+ */
335
+ export async function load(url, context, nextLoad) {
336
+ const result = await nextLoad(url, context);
337
+
338
+ // Only transform ESM modules we should observe
339
+ if (!shouldObserve(url)) return result;
340
+ const isModule = result.format === 'module';
341
+ const isTypeScript = result.format === 'module-typescript';
342
+ if (!isModule && !isTypeScript) return result;
343
+
344
+ const source = result.source;
345
+ if (!source) return result;
346
+
347
+ const sourceStr = typeof source === 'string'
348
+ ? source
349
+ : Buffer.from(source).toString('utf-8');
350
+
351
+ // Only transform if the module has exports
352
+ if (!sourceStr.includes('export ')) return result;
353
+
354
+ try {
355
+ const transformed = transformSource(sourceStr, url);
356
+ return {
357
+ ...result,
358
+ source: transformed,
359
+ shortCircuit: true,
360
+ };
361
+ } catch (err) {
362
+ if (config.debug) {
363
+ console.error(`[trickle/esm] Failed to transform ${url}:`, err.message);
364
+ }
365
+ return result;
366
+ }
367
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ESM auto-observation registration script.
3
+ *
4
+ * Usage:
5
+ * node --import trickle/observe-esm app.mjs
6
+ *
7
+ * Registers ESM loader hooks that wrap exported functions from user modules
8
+ * with trickle observation — capturing types and sample data for every call.
9
+ */
10
+ import { register } from 'node:module';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const hooksPath = join(__dirname, 'observe-esm-hooks.mjs');
17
+
18
+ const backendUrl = process.env.TRICKLE_BACKEND_URL || 'http://localhost:4888';
19
+ const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
20
+
21
+ if (debug) {
22
+ console.log(`[trickle/esm] Registering ESM observation hooks (backend: ${backendUrl})`);
23
+ }
24
+
25
+ register(pathToFileURL(hooksPath).href, {
26
+ parentURL: import.meta.url,
27
+ data: {
28
+ wrapperPath: join(__dirname, 'dist', 'wrap.js'),
29
+ transportPath: join(__dirname, 'dist', 'transport.js'),
30
+ envDetectPath: join(__dirname, 'dist', 'env-detect.js'),
31
+ backendUrl,
32
+ debug,
33
+ includePatterns: process.env.TRICKLE_OBSERVE_INCLUDE
34
+ ? process.env.TRICKLE_OBSERVE_INCLUDE.split(',').map(s => s.trim())
35
+ : [],
36
+ excludePatterns: process.env.TRICKLE_OBSERVE_EXCLUDE
37
+ ? process.env.TRICKLE_OBSERVE_EXCLUDE.split(',').map(s => s.trim())
38
+ : [],
39
+ },
40
+ });
package/observe.js ADDED
@@ -0,0 +1,2 @@
1
+ // Entry point for: node -r trickle/observe app.js
2
+ require('./dist/observe-register');
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "trickle-observe",
3
+ "version": "0.1.0",
4
+ "description": "Runtime type observability for JavaScript applications",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": "./dist/index.js",
9
+ "./register": "./register.js",
10
+ "./observe": "./observe.js",
11
+ "./observe-esm": "./observe-esm.mjs",
12
+ "./auto": "./auto.js",
13
+ "./auto-env": "./auto-env.js",
14
+ "./auto-esm": "./auto-esm.mjs"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {},
21
+ "devDependencies": {
22
+ "typescript": "^5.4.0"
23
+ },
24
+ "keywords": ["observability", "types", "runtime", "lambda", "express"],
25
+ "license": "MIT"
26
+ }
package/register.js ADDED
@@ -0,0 +1,2 @@
1
+ // Entry point for: node -r trickle/register app.js
2
+ require('./dist/register');