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,102 @@
1
+ /**
2
+ * trickle/auto — Zero-config type generation.
3
+ *
4
+ * Add ONE LINE to your app and types appear automatically:
5
+ *
6
+ * require('trickle/auto');
7
+ *
8
+ * This module:
9
+ * 1. Forces local mode (no backend needed)
10
+ * 2. Activates the observe-register hooks (instruments all user functions)
11
+ * 3. Runs a background timer that generates .d.ts files from observations
12
+ * 4. On process exit, does a final type generation
13
+ *
14
+ * No CLI. No backend. No configuration. Just types.
15
+ */
16
+
17
+ // Force local mode BEFORE importing observe-register (which calls configure)
18
+ process.env.TRICKLE_LOCAL = '1';
19
+
20
+ // Import the observe-register hooks (instruments all functions)
21
+ import './observe-register';
22
+
23
+ // Import the auto codegen
24
+ import { generateTypes, injectTypes, generateCoverageReport, generateTypeSummary } from './auto-codegen';
25
+
26
+ const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
27
+ let lastFunctionCount = 0;
28
+ let generationCount = 0;
29
+
30
+ /**
31
+ * Run type generation and optionally log results.
32
+ */
33
+ function runGeneration(isFinal: boolean): void {
34
+ try {
35
+ const count = generateTypes();
36
+ if (count === -1) return; // no change
37
+
38
+ if (count > 0) {
39
+ generationCount++;
40
+ if (debug || (count > lastFunctionCount)) {
41
+ const newTypes = count - lastFunctionCount;
42
+ if (newTypes > 0 && generationCount > 1) {
43
+ // Only log after first generation (avoid noise on startup)
44
+ console.log(`[trickle/auto] +${newTypes} type(s) generated (${count} total)`);
45
+ }
46
+ }
47
+ lastFunctionCount = count;
48
+ }
49
+
50
+ if (isFinal && lastFunctionCount > 0) {
51
+ console.log(`[trickle/auto] ${lastFunctionCount} function type(s) written to .d.ts`);
52
+ // Inject JSDoc into source files if TRICKLE_INJECT=1
53
+ try {
54
+ const injected = injectTypes();
55
+ if (injected > 0) {
56
+ console.log(`[trickle/auto] ${injected} function(s) annotated with JSDoc in source`);
57
+ }
58
+ } catch { /* don't crash */ }
59
+ // Print coverage report if TRICKLE_COVERAGE=1
60
+ try {
61
+ const report = generateCoverageReport();
62
+ if (report) console.log(report);
63
+ } catch { /* don't crash */ }
64
+ // Print type summary if TRICKLE_SUMMARY=1
65
+ try {
66
+ const summary = generateTypeSummary();
67
+ if (summary) console.log(summary);
68
+ } catch { /* don't crash */ }
69
+ }
70
+ } catch {
71
+ // Never crash user's app
72
+ }
73
+ }
74
+
75
+ // Background timer — regenerate types every 3 seconds
76
+ const timer = setInterval(() => runGeneration(false), 3000);
77
+
78
+ // Don't keep the process alive just for type generation
79
+ if (timer && typeof timer === 'object' && 'unref' in timer) {
80
+ (timer as any).unref();
81
+ }
82
+
83
+ // Also do a first check after 1 second
84
+ const initialTimer = setTimeout(() => runGeneration(false), 1000);
85
+ if (initialTimer && typeof initialTimer === 'object' && 'unref' in initialTimer) {
86
+ (initialTimer as any).unref();
87
+ }
88
+
89
+ // Final generation on exit
90
+ if (typeof process !== 'undefined' && process.on) {
91
+ process.on('beforeExit', () => {
92
+ runGeneration(true);
93
+ });
94
+
95
+ // On SIGTERM/SIGINT, do final generation
96
+ const exitHandler = () => {
97
+ clearInterval(timer);
98
+ runGeneration(true);
99
+ };
100
+ process.on('SIGTERM', exitHandler);
101
+ process.on('SIGINT', exitHandler);
102
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * In-memory cache to avoid sending redundant type data to the backend.
3
+ * Keyed by function identity (name + module), stores the last type hash sent.
4
+ * Re-sends if the hash changes or if the entry becomes stale.
5
+ */
6
+ export class TypeCache {
7
+ private cache: Map<string, { hash: string; lastSentAt: number }>;
8
+ private maxStalenessMs: number;
9
+
10
+ constructor(maxStalenessMs: number = 5 * 60 * 1000) {
11
+ this.cache = new Map();
12
+ this.maxStalenessMs = maxStalenessMs;
13
+ }
14
+
15
+ /**
16
+ * Returns true if this type observation should be sent to the backend.
17
+ * Returns false if we recently sent the same hash for this function.
18
+ */
19
+ shouldSend(functionKey: string, hash: string): boolean {
20
+ const entry = this.cache.get(functionKey);
21
+ if (!entry) return true;
22
+
23
+ // Different type shape — always send
24
+ if (entry.hash !== hash) return true;
25
+
26
+ // Same shape but stale — re-send to confirm it's still alive
27
+ const age = Date.now() - entry.lastSentAt;
28
+ if (age > this.maxStalenessMs) return true;
29
+
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * Mark that we just sent data for this function with this hash.
35
+ */
36
+ markSent(functionKey: string, hash: string): void {
37
+ this.cache.set(functionKey, { hash, lastSentAt: Date.now() });
38
+ }
39
+
40
+ /**
41
+ * Clear the cache (useful for testing or reconfiguration).
42
+ */
43
+ clear(): void {
44
+ this.cache.clear();
45
+ }
46
+
47
+ /**
48
+ * Get the number of cached entries.
49
+ */
50
+ get size(): number {
51
+ return this.cache.size;
52
+ }
53
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Detect the runtime environment from environment variables.
3
+ * Returns a human-readable string identifying where this code is running.
4
+ */
5
+ export function detectEnvironment(): string {
6
+ const env = typeof process !== 'undefined' && process.env ? process.env : {};
7
+
8
+ if (env.AWS_LAMBDA_FUNCTION_NAME) return 'lambda';
9
+ if (env.VERCEL) return 'vercel';
10
+ if (env.RAILWAY_ENVIRONMENT) return 'railway';
11
+ if (env.K_SERVICE) return 'cloud-run';
12
+ if (env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure-functions';
13
+ if (env.GOOGLE_CLOUD_PROJECT) return 'gcp';
14
+ if (env.ECS_CONTAINER_METADATA_URI) return 'ecs';
15
+ if (env.FLY_APP_NAME) return 'fly';
16
+ if (env.RENDER_SERVICE_ID) return 'render';
17
+ if (env.HEROKU_APP_NAME || env.DYNO) return 'heroku';
18
+ if (env.CODESPACE_NAME) return 'codespace';
19
+ if (env.GITPOD_WORKSPACE_ID) return 'gitpod';
20
+
21
+ return 'node';
22
+ }
package/src/express.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { TypeNode, IngestPayload, WrapOptions } from './types';
2
+ import { inferType } from './type-inference';
3
+ import { hashType } from './type-hash';
4
+ import { TypeCache } from './cache';
5
+ import { enqueue } from './transport';
6
+ import { detectEnvironment } from './env-detect';
7
+
8
+ const expressCache = new TypeCache();
9
+
10
+ /** Options shared across Express instrumentation. */
11
+ interface ExpressInstrumentOpts {
12
+ enabled: boolean;
13
+ environment: string;
14
+ sampleRate: number;
15
+ maxDepth: number;
16
+ }
17
+
18
+ /**
19
+ * Extract the interesting parts of an Express request as a plain object
20
+ * suitable for type inference. We deliberately avoid the full req object
21
+ * because it is enormous and contains circular references.
22
+ */
23
+ function extractRequestInput(req: any): Record<string, unknown> {
24
+ const input: Record<string, unknown> = {};
25
+
26
+ try {
27
+ if (req.body !== undefined && req.body !== null && Object.keys(req.body).length > 0) {
28
+ input.body = req.body;
29
+ }
30
+ } catch {
31
+ // body might not be readable
32
+ }
33
+
34
+ try {
35
+ if (req.params && Object.keys(req.params).length > 0) {
36
+ input.params = req.params;
37
+ }
38
+ } catch {
39
+ // ignore
40
+ }
41
+
42
+ try {
43
+ if (req.query && Object.keys(req.query).length > 0) {
44
+ input.query = req.query;
45
+ }
46
+ } catch {
47
+ // ignore
48
+ }
49
+
50
+ return input;
51
+ }
52
+
53
+ /**
54
+ * Sanitize a sample value for safe serialization (local copy to avoid circular import).
55
+ */
56
+ function sanitizeSample(value: unknown, depth: number = 3): unknown {
57
+ if (depth <= 0) return '[truncated]';
58
+ if (value === null || value === undefined) return value;
59
+
60
+ const t = typeof value;
61
+ if (t === 'string') {
62
+ const s = value as string;
63
+ return s.length > 200 ? s.substring(0, 200) + '...' : s;
64
+ }
65
+ if (t === 'number' || t === 'boolean') return value;
66
+ if (t === 'bigint') return String(value);
67
+ if (t === 'symbol') return String(value);
68
+ if (t === 'function') return `[Function: ${(value as Function).name || 'anonymous'}]`;
69
+
70
+ if (Array.isArray(value)) {
71
+ return value.slice(0, 5).map(item => sanitizeSample(item, depth - 1));
72
+ }
73
+
74
+ if (t === 'object') {
75
+ if (value instanceof Date) return value.toISOString();
76
+ if (value instanceof RegExp) return String(value);
77
+ if (value instanceof Error) return { error: value.message };
78
+ if (value instanceof Map) return `[Map: ${value.size} entries]`;
79
+ if (value instanceof Set) return `[Set: ${value.size} items]`;
80
+
81
+ const obj = value as Record<string, unknown>;
82
+ const result: Record<string, unknown> = {};
83
+ const keys = Object.keys(obj).slice(0, 20);
84
+ for (const key of keys) {
85
+ try {
86
+ result[key] = sanitizeSample(obj[key], depth - 1);
87
+ } catch {
88
+ result[key] = '[unreadable]';
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ return String(value);
95
+ }
96
+
97
+ /**
98
+ * Emit a type payload for a single Express route invocation.
99
+ */
100
+ function emitExpressPayload(
101
+ functionName: string,
102
+ environment: string,
103
+ maxDepth: number,
104
+ input: Record<string, unknown>,
105
+ output: unknown,
106
+ error?: unknown,
107
+ ): void {
108
+ try {
109
+ const functionKey = `express::${functionName}`;
110
+ const argsType = inferType(input, maxDepth);
111
+ const returnType = error ? ({ kind: 'unknown' } as TypeNode) : inferType(output, maxDepth);
112
+ const hash = hashType(argsType, returnType);
113
+
114
+ // For errors, always send. For success, use cache.
115
+ if (!error && !expressCache.shouldSend(functionKey, hash)) {
116
+ return;
117
+ }
118
+
119
+ if (!error) {
120
+ expressCache.markSent(functionKey, hash);
121
+ }
122
+
123
+ const payload: IngestPayload = {
124
+ functionName,
125
+ module: 'express',
126
+ language: 'js',
127
+ environment,
128
+ typeHash: hash,
129
+ argsType,
130
+ returnType,
131
+ sampleInput: sanitizeSample(input),
132
+ sampleOutput: error ? undefined : sanitizeSample(output),
133
+ };
134
+
135
+ if (error) {
136
+ const err = error instanceof Error ? error : new Error(String(error));
137
+ payload.error = {
138
+ type: err.constructor?.name || 'Error',
139
+ message: err.message,
140
+ stackTrace: err.stack,
141
+ argsSnapshot: sanitizeSample(input),
142
+ };
143
+ }
144
+
145
+ enqueue(payload);
146
+ } catch {
147
+ // Never crash the user's app
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Wrap a single Express route handler so that it captures request input
153
+ * (body, params, query) and response output (json/send payloads) as type data.
154
+ */
155
+ function wrapExpressHandler(
156
+ handler: Function,
157
+ routeName: string,
158
+ opts: ExpressInstrumentOpts,
159
+ ): Function {
160
+ const wrapped = function (this: any, req: any, res: any, next: any) {
161
+ // Sample rate check
162
+ if (opts.sampleRate < 1 && Math.random() > opts.sampleRate) {
163
+ return handler.call(this, req, res, next);
164
+ }
165
+
166
+ const input = extractRequestInput(req);
167
+ let captured = false;
168
+
169
+ // Intercept res.json()
170
+ const originalJson = res.json;
171
+ if (typeof originalJson === 'function') {
172
+ res.json = function (data: any) {
173
+ if (!captured) {
174
+ captured = true;
175
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, data);
176
+ }
177
+ res.json = originalJson; // restore
178
+ return originalJson.call(res, data);
179
+ };
180
+ }
181
+
182
+ // Intercept res.send()
183
+ const originalSend = res.send;
184
+ if (typeof originalSend === 'function') {
185
+ res.send = function (data: any) {
186
+ if (!captured) {
187
+ captured = true;
188
+ // Only capture non-string data as typed output; strings are usually HTML
189
+ const output = typeof data === 'string' ? { __html: true } : data;
190
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, output);
191
+ }
192
+ res.send = originalSend; // restore
193
+ return originalSend.call(res, data);
194
+ };
195
+ }
196
+
197
+ // Wrap next to capture errors passed via next(err)
198
+ const wrappedNext = function (err?: any) {
199
+ if (err && !captured) {
200
+ captured = true;
201
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
202
+ }
203
+ if (typeof next === 'function') {
204
+ return next(err);
205
+ }
206
+ };
207
+
208
+ try {
209
+ const result = handler.call(this, req, res, wrappedNext);
210
+
211
+ // Handle async handlers that return a promise
212
+ if (result && typeof result === 'object' && typeof result.then === 'function') {
213
+ return result.catch((err: unknown) => {
214
+ if (!captured) {
215
+ captured = true;
216
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
217
+ }
218
+ // Re-throw so Express error handling picks it up
219
+ throw err;
220
+ });
221
+ }
222
+
223
+ return result;
224
+ } catch (err) {
225
+ if (!captured) {
226
+ captured = true;
227
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
228
+ }
229
+ throw err;
230
+ }
231
+ };
232
+
233
+ // Preserve function metadata
234
+ Object.defineProperty(wrapped, 'name', { value: handler.name || routeName, configurable: true });
235
+ Object.defineProperty(wrapped, 'length', { value: handler.length, configurable: true });
236
+
237
+ return wrapped;
238
+ }
239
+
240
+ /**
241
+ * Instrument an Express application by monkey-patching route registration methods.
242
+ *
243
+ * Must be called BEFORE routes are defined:
244
+ *
245
+ * import express from 'express';
246
+ * import { instrumentExpress } from 'trickle';
247
+ *
248
+ * const app = express();
249
+ * instrumentExpress(app);
250
+ *
251
+ * app.get('/api/users', (req, res) => { ... });
252
+ *
253
+ * Each registered handler is wrapped to capture:
254
+ * - Input: `{ body, params, query }` from the request
255
+ * - Output: the data passed to `res.json()` or `res.send()`
256
+ * - Errors: exceptions or `next(err)` calls
257
+ */
258
+ export function instrumentExpress(
259
+ app: any,
260
+ userOpts?: { enabled?: boolean; environment?: string; sampleRate?: number; maxDepth?: number },
261
+ ): void {
262
+ const opts: ExpressInstrumentOpts = {
263
+ enabled: userOpts?.enabled !== false,
264
+ environment: userOpts?.environment || detectEnvironment(),
265
+ sampleRate: userOpts?.sampleRate ?? 1,
266
+ maxDepth: userOpts?.maxDepth ?? 5,
267
+ };
268
+
269
+ if (!opts.enabled) return;
270
+
271
+ const methods = ['get', 'post', 'put', 'delete', 'patch', 'all'] as const;
272
+
273
+ for (const method of methods) {
274
+ const original = app[method];
275
+ if (typeof original !== 'function') continue;
276
+
277
+ app[method] = function (this: any, path: string | RegExp, ...handlers: any[]) {
278
+ // Express allows non-string first args (RegExp, array of paths, etc.)
279
+ // We only label with a string path; otherwise fall back to the method name.
280
+ const pathStr = typeof path === 'string' ? path : String(path);
281
+ const routeName = `${method.toUpperCase()} ${pathStr}`;
282
+
283
+ const wrapped = handlers.map((handler: any) => {
284
+ if (typeof handler !== 'function') return handler;
285
+
286
+ try {
287
+ return wrapExpressHandler(handler, routeName, opts);
288
+ } catch {
289
+ // If wrapping fails for any reason, return the original handler
290
+ return handler;
291
+ }
292
+ });
293
+
294
+ return original.call(this, path, ...wrapped);
295
+ };
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Express middleware that intercepts responses to capture type information.
301
+ *
302
+ * Use this when you prefer middleware over monkey-patching:
303
+ *
304
+ * import express from 'express';
305
+ * import { trickleMiddleware } from 'trickle';
306
+ *
307
+ * const app = express();
308
+ * app.use(trickleMiddleware());
309
+ *
310
+ * The middleware captures the route `METHOD /path` once the response is sent,
311
+ * by intercepting `res.json()` and `res.send()`.
312
+ */
313
+ export function trickleMiddleware(
314
+ userOpts?: { enabled?: boolean; environment?: string; sampleRate?: number; maxDepth?: number },
315
+ ): (req: any, res: any, next: (...args: any[]) => void) => void {
316
+ const opts: ExpressInstrumentOpts = {
317
+ enabled: userOpts?.enabled !== false,
318
+ environment: userOpts?.environment || detectEnvironment(),
319
+ sampleRate: userOpts?.sampleRate ?? 1,
320
+ maxDepth: userOpts?.maxDepth ?? 5,
321
+ };
322
+
323
+ return function trickleMiddlewareHandler(req: any, res: any, next: (...args: any[]) => void): void {
324
+ if (!opts.enabled) {
325
+ next();
326
+ return;
327
+ }
328
+
329
+ // Sample rate check
330
+ if (opts.sampleRate < 1 && Math.random() > opts.sampleRate) {
331
+ next();
332
+ return;
333
+ }
334
+
335
+ let captured = false;
336
+
337
+ // We derive the route name lazily once the response is being sent,
338
+ // because req.route is only populated after the handler matches.
339
+ function getRouteName(): string {
340
+ try {
341
+ if (req.route && req.route.path) {
342
+ return `${req.method} ${req.baseUrl || ''}${req.route.path}`;
343
+ }
344
+ } catch {
345
+ // ignore
346
+ }
347
+ return `${req.method || 'UNKNOWN'} ${req.originalUrl || req.url || '/'}`;
348
+ }
349
+
350
+ const input = extractRequestInput(req);
351
+
352
+ // Intercept res.json()
353
+ const originalJson = res.json;
354
+ if (typeof originalJson === 'function') {
355
+ res.json = function (data: any) {
356
+ if (!captured) {
357
+ captured = true;
358
+ const routeName = getRouteName();
359
+ // Re-extract input here because body parsers may have run since middleware was entered
360
+ const latestInput = extractRequestInput(req);
361
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data);
362
+ }
363
+ res.json = originalJson;
364
+ return originalJson.call(res, data);
365
+ };
366
+ }
367
+
368
+ // Intercept res.send()
369
+ const originalSend = res.send;
370
+ if (typeof originalSend === 'function') {
371
+ res.send = function (data: any) {
372
+ if (!captured) {
373
+ captured = true;
374
+ const routeName = getRouteName();
375
+ const latestInput = extractRequestInput(req);
376
+ const output = typeof data === 'string' ? { __html: true } : data;
377
+ emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output);
378
+ }
379
+ res.send = originalSend;
380
+ return originalSend.call(res, data);
381
+ };
382
+ }
383
+
384
+ next();
385
+ };
386
+ }