trickle-observe 0.2.60 → 0.2.62

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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * trickle/lambda — Zero-config observability for AWS Lambda functions.
3
+ *
4
+ * Wraps a Lambda handler to:
5
+ * 1. Auto-instrument all functions with type observation
6
+ * 2. Write observations to /tmp/.trickle/ (Lambda's writable filesystem)
7
+ * OR send to TRICKLE_BACKEND_URL if set
8
+ * 3. Flush all pending observations synchronously before Lambda freezes
9
+ *
10
+ * Usage:
11
+ *
12
+ * import { wrapLambda } from 'trickle-observe/lambda';
13
+ *
14
+ * export const handler = wrapLambda(async (event, context) => {
15
+ * const result = await processOrder(event.orderId);
16
+ * return { statusCode: 200, body: JSON.stringify(result) };
17
+ * });
18
+ *
19
+ * OR zero-code via NODE_OPTIONS:
20
+ *
21
+ * NODE_OPTIONS="--require trickle-observe/auto-env" TRICKLE_AUTO=1
22
+ *
23
+ * Environment variables:
24
+ * TRICKLE_BACKEND_URL — Send observations to HTTP backend (optional)
25
+ * TRICKLE_LOCAL_DIR — Override local dir (default: /tmp/.trickle)
26
+ * TRICKLE_DEBUG — Set to "1" for debug logging
27
+ * TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to observe
28
+ * TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to skip
29
+ */
30
+ import './observe-register';
31
+ export type LambdaEvent = Record<string, unknown>;
32
+ export type LambdaContext = {
33
+ functionName: string;
34
+ functionVersion: string;
35
+ invokedFunctionArn: string;
36
+ awsRequestId: string;
37
+ remainingTimeInMillis?: () => number;
38
+ [key: string]: unknown;
39
+ };
40
+ export type LambdaHandler<E = LambdaEvent, R = unknown> = (event: E, context: LambdaContext) => Promise<R>;
41
+ /**
42
+ * Wrap a Lambda handler with trickle observability.
43
+ *
44
+ * Instruments all called functions automatically, writes type observations
45
+ * to /tmp/.trickle/, and flushes before Lambda freezes the process.
46
+ */
47
+ export declare function wrapLambda<E = LambdaEvent, R = unknown>(handler: LambdaHandler<E, R>): LambdaHandler<E, R>;
48
+ /**
49
+ * Print the contents of the local trickle observations directory to stdout
50
+ * as newline-delimited JSON. Useful for inspecting Lambda observations in
51
+ * CloudWatch Logs when TRICKLE_BACKEND_URL is not set.
52
+ *
53
+ * Call at the end of your handler to stream observations to CloudWatch:
54
+ *
55
+ * export const handler = wrapLambda(async (event) => {
56
+ * const result = await processOrder(event.orderId);
57
+ * printObservations(); // → streamed to CloudWatch Logs
58
+ * return result;
59
+ * });
60
+ */
61
+ export declare function printObservations(): void;
package/dist/lambda.js ADDED
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ /**
3
+ * trickle/lambda — Zero-config observability for AWS Lambda functions.
4
+ *
5
+ * Wraps a Lambda handler to:
6
+ * 1. Auto-instrument all functions with type observation
7
+ * 2. Write observations to /tmp/.trickle/ (Lambda's writable filesystem)
8
+ * OR send to TRICKLE_BACKEND_URL if set
9
+ * 3. Flush all pending observations synchronously before Lambda freezes
10
+ *
11
+ * Usage:
12
+ *
13
+ * import { wrapLambda } from 'trickle-observe/lambda';
14
+ *
15
+ * export const handler = wrapLambda(async (event, context) => {
16
+ * const result = await processOrder(event.orderId);
17
+ * return { statusCode: 200, body: JSON.stringify(result) };
18
+ * });
19
+ *
20
+ * OR zero-code via NODE_OPTIONS:
21
+ *
22
+ * NODE_OPTIONS="--require trickle-observe/auto-env" TRICKLE_AUTO=1
23
+ *
24
+ * Environment variables:
25
+ * TRICKLE_BACKEND_URL — Send observations to HTTP backend (optional)
26
+ * TRICKLE_LOCAL_DIR — Override local dir (default: /tmp/.trickle)
27
+ * TRICKLE_DEBUG — Set to "1" for debug logging
28
+ * TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to observe
29
+ * TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to skip
30
+ */
31
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
32
+ if (k2 === undefined) k2 = k;
33
+ var desc = Object.getOwnPropertyDescriptor(m, k);
34
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
35
+ desc = { enumerable: true, get: function() { return m[k]; } };
36
+ }
37
+ Object.defineProperty(o, k2, desc);
38
+ }) : (function(o, m, k, k2) {
39
+ if (k2 === undefined) k2 = k;
40
+ o[k2] = m[k];
41
+ }));
42
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
43
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
44
+ }) : function(o, v) {
45
+ o["default"] = v;
46
+ });
47
+ var __importStar = (this && this.__importStar) || (function () {
48
+ var ownKeys = function(o) {
49
+ ownKeys = Object.getOwnPropertyNames || function (o) {
50
+ var ar = [];
51
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
52
+ return ar;
53
+ };
54
+ return ownKeys(o);
55
+ };
56
+ return function (mod) {
57
+ if (mod && mod.__esModule) return mod;
58
+ var result = {};
59
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
60
+ __setModuleDefault(result, mod);
61
+ return result;
62
+ };
63
+ })();
64
+ Object.defineProperty(exports, "__esModule", { value: true });
65
+ exports.wrapLambda = wrapLambda;
66
+ exports.printObservations = printObservations;
67
+ const path = __importStar(require("path"));
68
+ const fs = __importStar(require("fs"));
69
+ const transport_1 = require("./transport");
70
+ const trace_var_1 = require("./trace-var");
71
+ // Install Module._compile and Module._load hooks for auto-instrumentation
72
+ require("./observe-register");
73
+ let initialized = false;
74
+ function initOnce() {
75
+ if (initialized)
76
+ return;
77
+ initialized = true;
78
+ const debug = process.env.TRICKLE_DEBUG === '1';
79
+ const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
80
+ // Ensure /tmp/.trickle exists (writable in Lambda, unlike /var/task)
81
+ try {
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ }
84
+ catch { }
85
+ if (process.env.TRICKLE_BACKEND_URL) {
86
+ // HTTP mode: send to developer's backend (e.g. via ngrok)
87
+ (0, transport_1.configure)({
88
+ backendUrl: process.env.TRICKLE_BACKEND_URL,
89
+ batchIntervalMs: 100, // aggressive batching for Lambda
90
+ enabled: true,
91
+ debug,
92
+ environment: 'node',
93
+ });
94
+ if (debug)
95
+ console.log(`[trickle/lambda] HTTP mode → ${process.env.TRICKLE_BACKEND_URL}`);
96
+ }
97
+ else {
98
+ // Local file mode: write to /tmp/.trickle/observations.jsonl
99
+ process.env.TRICKLE_LOCAL = '1';
100
+ process.env.TRICKLE_LOCAL_DIR = dir;
101
+ (0, transport_1.configure)({ backendUrl: 'http://localhost:4888', batchIntervalMs: 2000, enabled: true, debug, environment: 'node' });
102
+ if (debug)
103
+ console.log(`[trickle/lambda] Local mode → ${dir}/observations.jsonl`);
104
+ }
105
+ // Initialize variable tracer (writes to variables.jsonl)
106
+ (0, trace_var_1.initVarTracer)({ debug });
107
+ }
108
+ /**
109
+ * Wrap a Lambda handler with trickle observability.
110
+ *
111
+ * Instruments all called functions automatically, writes type observations
112
+ * to /tmp/.trickle/, and flushes before Lambda freezes the process.
113
+ */
114
+ function wrapLambda(handler) {
115
+ return async (event, context) => {
116
+ initOnce();
117
+ const debug = process.env.TRICKLE_DEBUG === '1';
118
+ const startMs = Date.now();
119
+ try {
120
+ const result = await handler(event, context);
121
+ // Flush pending HTTP observations before Lambda freezes the process.
122
+ // File-mode writes are already synchronous, so no flush needed there.
123
+ if (process.env.TRICKLE_BACKEND_URL) {
124
+ await (0, transport_1.flush)();
125
+ }
126
+ if (debug) {
127
+ const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
128
+ const elapsed = Date.now() - startMs;
129
+ const varsFile = path.join(dir, 'variables.jsonl');
130
+ try {
131
+ const size = fs.statSync(varsFile).size;
132
+ console.log(`[trickle/lambda] Flushed in ${elapsed}ms, observations: ${varsFile} (${size}b)`);
133
+ }
134
+ catch { }
135
+ }
136
+ return result;
137
+ }
138
+ catch (err) {
139
+ // Flush even on errors so partial observations are captured
140
+ if (process.env.TRICKLE_BACKEND_URL) {
141
+ await (0, transport_1.flush)().catch(() => { });
142
+ }
143
+ throw err;
144
+ }
145
+ };
146
+ }
147
+ /**
148
+ * Print the contents of the local trickle observations directory to stdout
149
+ * as newline-delimited JSON. Useful for inspecting Lambda observations in
150
+ * CloudWatch Logs when TRICKLE_BACKEND_URL is not set.
151
+ *
152
+ * Call at the end of your handler to stream observations to CloudWatch:
153
+ *
154
+ * export const handler = wrapLambda(async (event) => {
155
+ * const result = await processOrder(event.orderId);
156
+ * printObservations(); // → streamed to CloudWatch Logs
157
+ * return result;
158
+ * });
159
+ */
160
+ function printObservations() {
161
+ const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
162
+ for (const file of ['variables.jsonl', 'observations.jsonl']) {
163
+ const filePath = path.join(dir, file);
164
+ try {
165
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
166
+ if (content) {
167
+ for (const line of content.split('\n')) {
168
+ if (line.trim()) {
169
+ // Prefix so trickle CLI can grep from CloudWatch Logs
170
+ console.log(`[trickle] ${line}`);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ catch { /* file may not exist yet */ }
176
+ }
177
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * trickle/metro-transformer — Metro bundler transform for React Native observability.
3
+ *
4
+ * Drop-in Metro transformer that instruments React Native components with
5
+ * trickle's render tracking, useState change tracking, and hook observability —
6
+ * the same tracking trickle's Vite plugin provides for web React apps.
7
+ *
8
+ * Setup in metro.config.js:
9
+ *
10
+ * const { getDefaultConfig } = require('expo/metro-config');
11
+ * // or: const { getDefaultConfig } = require('@react-native/metro-config');
12
+ *
13
+ * const config = getDefaultConfig(__dirname);
14
+ * config.transformer.babelTransformerPath = require.resolve('trickle-observe/metro-transformer');
15
+ * module.exports = config;
16
+ *
17
+ * Environment variables:
18
+ * TRICKLE_BACKEND_URL — URL of your trickle backend (default: http://localhost:4888)
19
+ * For real device: use your machine's LAN IP, e.g. http://192.168.1.5:4888
20
+ * TRICKLE_DEBUG — Set to "1" for debug logging
21
+ */
22
+ interface MetroTransformArgs {
23
+ src: string;
24
+ filename: string;
25
+ options: Record<string, unknown>;
26
+ }
27
+ export declare function transform({ src, filename, options }: MetroTransformArgs): Promise<any>;
28
+ export {};
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * trickle/metro-transformer — Metro bundler transform for React Native observability.
4
+ *
5
+ * Drop-in Metro transformer that instruments React Native components with
6
+ * trickle's render tracking, useState change tracking, and hook observability —
7
+ * the same tracking trickle's Vite plugin provides for web React apps.
8
+ *
9
+ * Setup in metro.config.js:
10
+ *
11
+ * const { getDefaultConfig } = require('expo/metro-config');
12
+ * // or: const { getDefaultConfig } = require('@react-native/metro-config');
13
+ *
14
+ * const config = getDefaultConfig(__dirname);
15
+ * config.transformer.babelTransformerPath = require.resolve('trickle-observe/metro-transformer');
16
+ * module.exports = config;
17
+ *
18
+ * Environment variables:
19
+ * TRICKLE_BACKEND_URL — URL of your trickle backend (default: http://localhost:4888)
20
+ * For real device: use your machine's LAN IP, e.g. http://192.168.1.5:4888
21
+ * TRICKLE_DEBUG — Set to "1" for debug logging
22
+ */
23
+ var __importDefault = (this && this.__importDefault) || function (mod) {
24
+ return (mod && mod.__esModule) ? mod : { "default": mod };
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.transform = transform;
28
+ const path_1 = __importDefault(require("path"));
29
+ const vite_plugin_1 = require("./vite-plugin");
30
+ // The upstream Babel transformer — try Expo first, fall back to bare RN
31
+ function getUpstreamTransformer() {
32
+ const candidates = [
33
+ '@expo/metro-config/babel-transformer',
34
+ 'metro-react-native-babel-transformer',
35
+ ];
36
+ for (const candidate of candidates) {
37
+ try {
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ return require(candidate);
40
+ }
41
+ catch {
42
+ // not installed, try next
43
+ }
44
+ }
45
+ throw new Error('[trickle/metro-transformer] Could not find a Metro Babel transformer. ' +
46
+ 'Install either @expo/metro-config or metro-react-native-babel-transformer.');
47
+ }
48
+ const backendUrl = process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
49
+ const debug = process.env.TRICKLE_DEBUG === '1';
50
+ async function transform({ src, filename, options }) {
51
+ const upstreamTransformer = getUpstreamTransformer();
52
+ // Only instrument React Native component files
53
+ const ext = path_1.default.extname(filename).toLowerCase();
54
+ const isReactFile = ext === '.tsx' || ext === '.jsx';
55
+ const isJsFile = ext === '.ts' || ext === '.js';
56
+ if ((isReactFile || isJsFile) && !filename.includes('node_modules') && !filename.includes('trickle-observe')) {
57
+ const moduleName = path_1.default.basename(filename).replace(/\.[jt]sx?$/, '');
58
+ const transformed = (0, vite_plugin_1.transformEsmSource)(src, filename, moduleName, backendUrl, debug, false);
59
+ if (transformed !== src) {
60
+ if (debug) {
61
+ console.log(`[trickle/metro] Instrumented ${filename}`);
62
+ }
63
+ return upstreamTransformer.transform({ src: transformed, filename, options });
64
+ }
65
+ }
66
+ return upstreamTransformer.transform({ src, filename, options });
67
+ }
package/dist/trace-var.js CHANGED
@@ -70,7 +70,10 @@ const MAX_BUFFER_SIZE = 100;
70
70
  */
71
71
  function initVarTracer(opts = {}) {
72
72
  debugMode = opts.debug === true;
73
- const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
73
+ // Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
74
+ const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
75
+ const defaultDir = isLambda ? '/tmp/.trickle' : path.join(process.cwd(), '.trickle');
76
+ const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
74
77
  try {
75
78
  fs.mkdirSync(dir, { recursive: true });
76
79
  }
package/dist/transport.js CHANGED
@@ -66,7 +66,10 @@ function configure(opts) {
66
66
  // Check for local/file-based mode
67
67
  if (process.env.TRICKLE_LOCAL === '1') {
68
68
  localMode = true;
69
- const dir = process.env.TRICKLE_LOCAL_DIR || pathMod.join(process.cwd(), '.trickle');
69
+ // Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
70
+ const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
71
+ const defaultDir = isLambda ? '/tmp/.trickle' : pathMod.join(process.cwd(), '.trickle');
72
+ const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
70
73
  try {
71
74
  fs.mkdirSync(dir, { recursive: true });
72
75
  }
@@ -37,4 +37,5 @@ export declare function tricklePlugin(options?: TricklePluginOptions): {
37
37
  map: null;
38
38
  } | null;
39
39
  };
40
+ export declare function transformEsmSource(source: string, filename: string, moduleName: string, backendUrl: string, debug: boolean, traceVars: boolean, originalSource?: string | null): string;
40
41
  export default tricklePlugin;
@@ -23,6 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
23
23
  };
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.tricklePlugin = tricklePlugin;
26
+ exports.transformEsmSource = transformEsmSource;
26
27
  const path_1 = __importDefault(require("path"));
27
28
  const fs_1 = __importDefault(require("fs"));
28
29
  function tricklePlugin(options = {}) {
@@ -496,8 +497,8 @@ function escapeRegexStr(str) {
496
497
  function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
497
498
  // Detect React files for component render tracking
498
499
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
499
- // Match top-level and nested function declarations (including async, export)
500
- const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
500
+ // Match top-level and nested function declarations (including async, export, export default)
501
+ const funcRegex = /^[ \t]*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
501
502
  const funcInsertions = [];
502
503
  // Body insertions: insert at start of function body (for React render tracking)
503
504
  // propsExpr: JS expression to evaluate as the props object at render time
@@ -487,11 +487,11 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
487
487
  function escapeRegexStr(str) {
488
488
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
489
489
  }
490
- function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
490
+ export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
491
491
  // Detect React files for component render tracking
492
492
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
493
- // Match top-level and nested function declarations (including async, export)
494
- const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
493
+ // Match top-level and nested function declarations (including async, export, export default)
494
+ const funcRegex = /^[ \t]*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
495
495
  const funcInsertions = [];
496
496
  // Body insertions: insert at start of function body (for React render tracking)
497
497
  // propsExpr: JS expression to evaluate as the props object at render time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,11 +16,13 @@
16
16
  "import": "./dist-esm/vite-plugin.js",
17
17
  "require": "./dist/vite-plugin.js"
18
18
  },
19
- "./trace-var": "./dist/trace-var.js"
19
+ "./trace-var": "./dist/trace-var.js",
20
+ "./lambda": "./dist/lambda.js",
21
+ "./metro-transformer": "./dist/metro-transformer.js"
20
22
  },
21
23
  "scripts": {
22
24
  "build": "tsc && tsc -p tsconfig.esm.json",
23
- "test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts",
25
+ "test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts src/lambda.test.ts src/metro-transformer.test.ts",
24
26
  "prepublishOnly": "npm run build"
25
27
  },
26
28
  "optionalDependencies": {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Unit tests for wrapLambda and printObservations.
3
+ *
4
+ * Run with: node --experimental-strip-types --test src/lambda.test.ts
5
+ */
6
+ import { describe, it, before, after } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+
12
+ // ── wrapLambda ────────────────────────────────────────────────────────────────
13
+
14
+ describe('wrapLambda', () => {
15
+ const makeContext = () => ({
16
+ functionName: 'test-fn',
17
+ functionVersion: '$LATEST',
18
+ invokedFunctionArn: 'arn:aws:lambda:us-east-1:123:function:test-fn',
19
+ awsRequestId: 'req-1',
20
+ });
21
+
22
+ let tmpDir: string;
23
+
24
+ before(() => {
25
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-lambda-test-'));
26
+ process.env.TRICKLE_LOCAL_DIR = tmpDir;
27
+ });
28
+
29
+ after(() => {
30
+ delete process.env.TRICKLE_LOCAL_DIR;
31
+ fs.rmSync(tmpDir, { recursive: true, force: true });
32
+ });
33
+
34
+ it('returns the handler result unchanged', async () => {
35
+ // Import fresh to avoid module cache issues with env vars
36
+ const { wrapLambda } = await import('../dist/lambda.js');
37
+ const handler = async (_event: unknown, _ctx: unknown) => ({ statusCode: 200, body: 'ok' });
38
+ const wrapped = wrapLambda(handler);
39
+ const result = await wrapped({ orderId: '123' }, makeContext());
40
+ assert.deepEqual(result, { statusCode: 200, body: 'ok' });
41
+ });
42
+
43
+ it('passes event and context to the handler', async () => {
44
+ const { wrapLambda } = await import('../dist/lambda.js');
45
+ let receivedEvent: unknown;
46
+ let receivedCtx: unknown;
47
+ const handler = async (event: unknown, ctx: unknown) => {
48
+ receivedEvent = event;
49
+ receivedCtx = ctx;
50
+ return 'done';
51
+ };
52
+ const wrapped = wrapLambda(handler);
53
+ const ctx = makeContext();
54
+ await wrapped({ hello: 'world' }, ctx);
55
+ assert.deepEqual(receivedEvent, { hello: 'world' });
56
+ assert.equal((receivedCtx as typeof ctx).awsRequestId, 'req-1');
57
+ });
58
+
59
+ it('re-throws errors from the handler', async () => {
60
+ const { wrapLambda } = await import('../dist/lambda.js');
61
+ const handler = async () => { throw new Error('Lambda error'); };
62
+ const wrapped = wrapLambda(handler);
63
+ await assert.rejects(() => wrapped({}, makeContext()), /Lambda error/);
64
+ });
65
+
66
+ it('does not throw if TRICKLE_BACKEND_URL is not set', async () => {
67
+ delete process.env.TRICKLE_BACKEND_URL;
68
+ const { wrapLambda } = await import('../dist/lambda.js');
69
+ const handler = async () => 42;
70
+ const wrapped = wrapLambda(handler);
71
+ const result = await wrapped({}, makeContext());
72
+ assert.equal(result, 42);
73
+ });
74
+ });
75
+
76
+ // ── printObservations ─────────────────────────────────────────────────────────
77
+
78
+ describe('printObservations', () => {
79
+ let tmpDir: string;
80
+ let originalDir: string | undefined;
81
+ let logged: string[];
82
+ let origLog: typeof console.log;
83
+
84
+ before(() => {
85
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-print-test-'));
86
+ originalDir = process.env.TRICKLE_LOCAL_DIR;
87
+ process.env.TRICKLE_LOCAL_DIR = tmpDir;
88
+ // Capture console.log output
89
+ origLog = console.log;
90
+ logged = [];
91
+ console.log = (...args: unknown[]) => { logged.push(args.join(' ')); };
92
+ });
93
+
94
+ after(() => {
95
+ console.log = origLog;
96
+ if (originalDir !== undefined) {
97
+ process.env.TRICKLE_LOCAL_DIR = originalDir;
98
+ } else {
99
+ delete process.env.TRICKLE_LOCAL_DIR;
100
+ }
101
+ fs.rmSync(tmpDir, { recursive: true, force: true });
102
+ });
103
+
104
+ it('prints [trickle] prefixed JSON lines from variables.jsonl', async () => {
105
+ const record = JSON.stringify({ kind: 'var', name: 'x', value: 1 });
106
+ fs.writeFileSync(path.join(tmpDir, 'variables.jsonl'), record + '\n');
107
+
108
+ const { printObservations } = await import('../dist/lambda.js');
109
+ logged = [];
110
+ printObservations();
111
+
112
+ assert.ok(logged.some(l => l.startsWith('[trickle]')), 'should prefix with [trickle]');
113
+ assert.ok(logged.some(l => l.includes('"kind"')), 'should include JSON content');
114
+ });
115
+
116
+ it('does not crash when no files exist', async () => {
117
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-empty-'));
118
+ const prevDir = process.env.TRICKLE_LOCAL_DIR;
119
+ process.env.TRICKLE_LOCAL_DIR = emptyDir;
120
+ try {
121
+ const { printObservations } = await import('../dist/lambda.js');
122
+ assert.doesNotThrow(() => printObservations());
123
+ } finally {
124
+ process.env.TRICKLE_LOCAL_DIR = prevDir;
125
+ fs.rmSync(emptyDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ it('prints observations from both variables.jsonl and observations.jsonl', async () => {
130
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-both-'));
131
+ const prevDir = process.env.TRICKLE_LOCAL_DIR;
132
+ process.env.TRICKLE_LOCAL_DIR = dir;
133
+ try {
134
+ fs.writeFileSync(path.join(dir, 'variables.jsonl'), JSON.stringify({ kind: 'var' }) + '\n');
135
+ fs.writeFileSync(path.join(dir, 'observations.jsonl'), JSON.stringify({ kind: 'obs' }) + '\n');
136
+
137
+ const { printObservations } = await import('../dist/lambda.js');
138
+ logged = [];
139
+ printObservations();
140
+ const count = logged.filter(l => l.startsWith('[trickle]')).length;
141
+ assert.ok(count >= 2, `should print at least 2 [trickle] lines, got ${count}`);
142
+ } finally {
143
+ process.env.TRICKLE_LOCAL_DIR = prevDir;
144
+ fs.rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ });
147
+ });
package/src/lambda.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * trickle/lambda — Zero-config observability for AWS Lambda functions.
3
+ *
4
+ * Wraps a Lambda handler to:
5
+ * 1. Auto-instrument all functions with type observation
6
+ * 2. Write observations to /tmp/.trickle/ (Lambda's writable filesystem)
7
+ * OR send to TRICKLE_BACKEND_URL if set
8
+ * 3. Flush all pending observations synchronously before Lambda freezes
9
+ *
10
+ * Usage:
11
+ *
12
+ * import { wrapLambda } from 'trickle-observe/lambda';
13
+ *
14
+ * export const handler = wrapLambda(async (event, context) => {
15
+ * const result = await processOrder(event.orderId);
16
+ * return { statusCode: 200, body: JSON.stringify(result) };
17
+ * });
18
+ *
19
+ * OR zero-code via NODE_OPTIONS:
20
+ *
21
+ * NODE_OPTIONS="--require trickle-observe/auto-env" TRICKLE_AUTO=1
22
+ *
23
+ * Environment variables:
24
+ * TRICKLE_BACKEND_URL — Send observations to HTTP backend (optional)
25
+ * TRICKLE_LOCAL_DIR — Override local dir (default: /tmp/.trickle)
26
+ * TRICKLE_DEBUG — Set to "1" for debug logging
27
+ * TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to observe
28
+ * TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to skip
29
+ */
30
+
31
+ import * as path from 'path';
32
+ import * as fs from 'fs';
33
+ import { configure, flush } from './transport';
34
+ import { initVarTracer } from './trace-var';
35
+ // Install Module._compile and Module._load hooks for auto-instrumentation
36
+ import './observe-register';
37
+
38
+ export type LambdaEvent = Record<string, unknown>;
39
+ export type LambdaContext = {
40
+ functionName: string;
41
+ functionVersion: string;
42
+ invokedFunctionArn: string;
43
+ awsRequestId: string;
44
+ remainingTimeInMillis?: () => number;
45
+ [key: string]: unknown;
46
+ };
47
+ export type LambdaHandler<E = LambdaEvent, R = unknown> = (
48
+ event: E,
49
+ context: LambdaContext,
50
+ ) => Promise<R>;
51
+
52
+ let initialized = false;
53
+
54
+ function initOnce() {
55
+ if (initialized) return;
56
+ initialized = true;
57
+
58
+ const debug = process.env.TRICKLE_DEBUG === '1';
59
+ const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
60
+
61
+ // Ensure /tmp/.trickle exists (writable in Lambda, unlike /var/task)
62
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
63
+
64
+ if (process.env.TRICKLE_BACKEND_URL) {
65
+ // HTTP mode: send to developer's backend (e.g. via ngrok)
66
+ configure({
67
+ backendUrl: process.env.TRICKLE_BACKEND_URL,
68
+ batchIntervalMs: 100, // aggressive batching for Lambda
69
+ enabled: true,
70
+ debug,
71
+ environment: 'node',
72
+ });
73
+ if (debug) console.log(`[trickle/lambda] HTTP mode → ${process.env.TRICKLE_BACKEND_URL}`);
74
+ } else {
75
+ // Local file mode: write to /tmp/.trickle/observations.jsonl
76
+ process.env.TRICKLE_LOCAL = '1';
77
+ process.env.TRICKLE_LOCAL_DIR = dir;
78
+ configure({ backendUrl: 'http://localhost:4888', batchIntervalMs: 2000, enabled: true, debug, environment: 'node' });
79
+ if (debug) console.log(`[trickle/lambda] Local mode → ${dir}/observations.jsonl`);
80
+ }
81
+
82
+ // Initialize variable tracer (writes to variables.jsonl)
83
+ initVarTracer({ debug });
84
+ }
85
+
86
+ /**
87
+ * Wrap a Lambda handler with trickle observability.
88
+ *
89
+ * Instruments all called functions automatically, writes type observations
90
+ * to /tmp/.trickle/, and flushes before Lambda freezes the process.
91
+ */
92
+ export function wrapLambda<E = LambdaEvent, R = unknown>(
93
+ handler: LambdaHandler<E, R>,
94
+ ): LambdaHandler<E, R> {
95
+ return async (event: E, context: LambdaContext): Promise<R> => {
96
+ initOnce();
97
+
98
+ const debug = process.env.TRICKLE_DEBUG === '1';
99
+ const startMs = Date.now();
100
+
101
+ try {
102
+ const result = await handler(event, context);
103
+
104
+ // Flush pending HTTP observations before Lambda freezes the process.
105
+ // File-mode writes are already synchronous, so no flush needed there.
106
+ if (process.env.TRICKLE_BACKEND_URL) {
107
+ await flush();
108
+ }
109
+
110
+ if (debug) {
111
+ const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
112
+ const elapsed = Date.now() - startMs;
113
+ const varsFile = path.join(dir, 'variables.jsonl');
114
+ try {
115
+ const size = fs.statSync(varsFile).size;
116
+ console.log(`[trickle/lambda] Flushed in ${elapsed}ms, observations: ${varsFile} (${size}b)`);
117
+ } catch {}
118
+ }
119
+
120
+ return result;
121
+ } catch (err) {
122
+ // Flush even on errors so partial observations are captured
123
+ if (process.env.TRICKLE_BACKEND_URL) {
124
+ await flush().catch(() => {});
125
+ }
126
+ throw err;
127
+ }
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Print the contents of the local trickle observations directory to stdout
133
+ * as newline-delimited JSON. Useful for inspecting Lambda observations in
134
+ * CloudWatch Logs when TRICKLE_BACKEND_URL is not set.
135
+ *
136
+ * Call at the end of your handler to stream observations to CloudWatch:
137
+ *
138
+ * export const handler = wrapLambda(async (event) => {
139
+ * const result = await processOrder(event.orderId);
140
+ * printObservations(); // → streamed to CloudWatch Logs
141
+ * return result;
142
+ * });
143
+ */
144
+ export function printObservations(): void {
145
+ const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
146
+ for (const file of ['variables.jsonl', 'observations.jsonl']) {
147
+ const filePath = path.join(dir, file);
148
+ try {
149
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
150
+ if (content) {
151
+ for (const line of content.split('\n')) {
152
+ if (line.trim()) {
153
+ // Prefix so trickle CLI can grep from CloudWatch Logs
154
+ console.log(`[trickle] ${line}`);
155
+ }
156
+ }
157
+ }
158
+ } catch { /* file may not exist yet */ }
159
+ }
160
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Unit tests for the Metro transformer (React Native observability).
3
+ *
4
+ * Tests the transformation logic independently — without needing a real Metro
5
+ * bundler or Babel pipeline. We test that transformEsmSource is applied correctly
6
+ * to React Native component source files.
7
+ *
8
+ * Run with: node --experimental-strip-types --test src/metro-transformer.test.ts
9
+ */
10
+ import { describe, it } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { transformEsmSource } from '../dist/vite-plugin.js';
13
+
14
+ const BACKEND_URL = 'http://localhost:4888';
15
+
16
+ // Helper: transform as if coming from a .tsx React Native file
17
+ function transformRNTsx(code: string, filename = '/app/components/MyComponent.tsx'): string {
18
+ return transformEsmSource(code, filename, 'MyComponent', BACKEND_URL, false, false);
19
+ }
20
+
21
+ // Helper: transform as if coming from a .ts utility file
22
+ function transformRNTs(code: string, filename = '/app/utils/helper.ts'): string {
23
+ return transformEsmSource(code, filename, 'helper', BACKEND_URL, false, false);
24
+ }
25
+
26
+ // ── Metro transformer: React component detection ──────────────────────────────
27
+
28
+ describe('Metro transformer: React component detection', () => {
29
+ it('instruments uppercase components in .tsx files', () => {
30
+ const code = `function OrderCard({ order }) { return null; }`;
31
+ const out = transformRNTsx(code);
32
+ assert.notEqual(out, code, 'should transform');
33
+ assert.ok(out.includes('__trickle_rc'), 'should inject render tracker');
34
+ });
35
+
36
+ it('does not inject render tracker for .ts utility files', () => {
37
+ const code = `function formatPrice(amount) { return '$' + amount; }`;
38
+ const out = transformRNTs(code);
39
+ assert.ok(!out.includes('__trickle_rc'), 'should not inject render tracker in .ts files');
40
+ });
41
+
42
+ it('instruments export default function components (common React Native screen pattern)', () => {
43
+ const code = `export default function HomeScreen() { return null; }`;
44
+ const out = transformRNTsx(code);
45
+ assert.ok(out.includes('__trickle_rc'), 'should inject render tracker for export default function');
46
+ });
47
+
48
+ it('does not instrument lowercase utility functions as components', () => {
49
+ const code = `function formatOrder(order) { return order.id; }`;
50
+ const out = transformRNTsx(code);
51
+ if (out !== code) {
52
+ assert.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked as component');
53
+ }
54
+ });
55
+ });
56
+
57
+ // ── Metro transformer: useState tracking ─────────────────────────────────────
58
+
59
+ describe('Metro transformer: useState tracking in React Native', () => {
60
+ it('tracks useState in a React Native functional component', () => {
61
+ const code = [
62
+ `import React, { useState } from 'react';`,
63
+ `function Counter() {`,
64
+ ` const [count, setCount] = useState(0);`,
65
+ ` return null;`,
66
+ `}`,
67
+ ].join('\n');
68
+ const out = transformRNTsx(code);
69
+ assert.ok(out.includes('__trickle_ss'), 'should inject state setter wrapper');
70
+ assert.ok(out.includes('__trickle_s_setCount'), 'should rename original setter');
71
+ assert.ok(out.includes('"count"'), 'should include state variable name');
72
+ });
73
+
74
+ it('tracks multiple useState calls in a React Native screen', () => {
75
+ const code = [
76
+ `function CheckoutScreen() {`,
77
+ ` const [items, setItems] = useState([]);`,
78
+ ` const [total, setTotal] = useState(0);`,
79
+ ` const [loading, setLoading] = useState(false);`,
80
+ ` return null;`,
81
+ `}`,
82
+ ].join('\n');
83
+ const out = transformRNTsx(code);
84
+ const count = (out.match(/const \w+=__trickle_ss/g) || []).length;
85
+ assert.equal(count, 3, 'should wrap all 3 useState setters');
86
+ });
87
+
88
+ it('handles TypeScript typed useState in React Native', () => {
89
+ const code = [
90
+ `function ProfileScreen() {`,
91
+ ` const [user, setUser] = useState<User | null>(null);`,
92
+ ` return null;`,
93
+ `}`,
94
+ ].join('\n');
95
+ const out = transformRNTsx(code);
96
+ assert.ok(out.includes('__trickle_ss'), 'should track typed useState');
97
+ assert.ok(out.includes('"user"'), 'should include state name');
98
+ });
99
+ });
100
+
101
+ // ── Metro transformer: hook observability ────────────────────────────────────
102
+
103
+ describe('Metro transformer: hook observability in React Native', () => {
104
+ it('wraps useEffect in a React Native component', () => {
105
+ const code = [
106
+ `function DataScreen() {`,
107
+ ` useEffect(() => {`,
108
+ ` fetchData();`,
109
+ ` }, []);`,
110
+ ` return null;`,
111
+ `}`,
112
+ ].join('\n');
113
+ const out = transformRNTsx(code);
114
+ assert.ok(out.includes('__trickle_hw'), 'should inject hook wrapper for useEffect');
115
+ assert.ok(out.includes('"useEffect"'), 'should record hook name');
116
+ });
117
+
118
+ it('wraps useCallback in a React Native component', () => {
119
+ const code = [
120
+ `function ListScreen() {`,
121
+ ` const handlePress = useCallback(() => {`,
122
+ ` navigate('Detail');`,
123
+ ` }, []);`,
124
+ ` return null;`,
125
+ `}`,
126
+ ].join('\n');
127
+ const out = transformRNTsx(code);
128
+ assert.ok(out.includes('__trickle_hw'), 'should inject hook wrapper for useCallback');
129
+ assert.ok(out.includes('"useCallback"'), 'should record hook name');
130
+ });
131
+ });
132
+
133
+ // ── Metro transformer: source unchanged for non-RN files ─────────────────────
134
+
135
+ describe('Metro transformer: passthrough for non-component files', () => {
136
+ it('does not modify a plain TypeScript utility file', () => {
137
+ const code = `export function add(a: number, b: number): number { return a + b; }`;
138
+ const out = transformRNTs(code);
139
+ // .ts files should not get __trickle_rc (no React components)
140
+ assert.ok(!out.includes('__trickle_rc'), 'should not inject React tracking in .ts files');
141
+ });
142
+
143
+ it('does not inject useState tracking in .ts files', () => {
144
+ const code = `function helper() {\n const [x, setX] = useState(0);\n return x;\n}`;
145
+ const out = transformRNTs(code);
146
+ assert.ok(!out.includes('__trickle_ss'), 'should not track useState in .ts files');
147
+ });
148
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * trickle/metro-transformer — Metro bundler transform for React Native observability.
3
+ *
4
+ * Drop-in Metro transformer that instruments React Native components with
5
+ * trickle's render tracking, useState change tracking, and hook observability —
6
+ * the same tracking trickle's Vite plugin provides for web React apps.
7
+ *
8
+ * Setup in metro.config.js:
9
+ *
10
+ * const { getDefaultConfig } = require('expo/metro-config');
11
+ * // or: const { getDefaultConfig } = require('@react-native/metro-config');
12
+ *
13
+ * const config = getDefaultConfig(__dirname);
14
+ * config.transformer.babelTransformerPath = require.resolve('trickle-observe/metro-transformer');
15
+ * module.exports = config;
16
+ *
17
+ * Environment variables:
18
+ * TRICKLE_BACKEND_URL — URL of your trickle backend (default: http://localhost:4888)
19
+ * For real device: use your machine's LAN IP, e.g. http://192.168.1.5:4888
20
+ * TRICKLE_DEBUG — Set to "1" for debug logging
21
+ */
22
+
23
+ import path from 'path';
24
+ import { transformEsmSource } from './vite-plugin';
25
+
26
+ // The upstream Babel transformer — try Expo first, fall back to bare RN
27
+ function getUpstreamTransformer() {
28
+ const candidates = [
29
+ '@expo/metro-config/babel-transformer',
30
+ 'metro-react-native-babel-transformer',
31
+ ];
32
+ for (const candidate of candidates) {
33
+ try {
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ return require(candidate);
36
+ } catch {
37
+ // not installed, try next
38
+ }
39
+ }
40
+ throw new Error(
41
+ '[trickle/metro-transformer] Could not find a Metro Babel transformer. ' +
42
+ 'Install either @expo/metro-config or metro-react-native-babel-transformer.',
43
+ );
44
+ }
45
+
46
+ interface MetroTransformArgs {
47
+ src: string;
48
+ filename: string;
49
+ options: Record<string, unknown>;
50
+ }
51
+
52
+ const backendUrl = process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
53
+ const debug = process.env.TRICKLE_DEBUG === '1';
54
+
55
+ export async function transform({ src, filename, options }: MetroTransformArgs) {
56
+ const upstreamTransformer = getUpstreamTransformer();
57
+
58
+ // Only instrument React Native component files
59
+ const ext = path.extname(filename).toLowerCase();
60
+ const isReactFile = ext === '.tsx' || ext === '.jsx';
61
+ const isJsFile = ext === '.ts' || ext === '.js';
62
+
63
+ if ((isReactFile || isJsFile) && !filename.includes('node_modules') && !filename.includes('trickle-observe')) {
64
+ const moduleName = path.basename(filename).replace(/\.[jt]sx?$/, '');
65
+ const transformed = transformEsmSource(src, filename, moduleName, backendUrl, debug, false);
66
+ if (transformed !== src) {
67
+ if (debug) {
68
+ console.log(`[trickle/metro] Instrumented ${filename}`);
69
+ }
70
+ return upstreamTransformer.transform({ src: transformed, filename, options });
71
+ }
72
+ }
73
+
74
+ return upstreamTransformer.transform({ src, filename, options });
75
+ }
package/src/trace-var.ts CHANGED
@@ -50,7 +50,10 @@ export interface VariableObservation {
50
50
  */
51
51
  export function initVarTracer(opts: { debug?: boolean } = {}): void {
52
52
  debugMode = opts.debug === true;
53
- const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
53
+ // Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
54
+ const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
55
+ const defaultDir = isLambda ? '/tmp/.trickle' : path.join(process.cwd(), '.trickle');
56
+ const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
54
57
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
55
58
  varsFilePath = path.join(dir, 'variables.jsonl');
56
59
 
package/src/transport.ts CHANGED
@@ -32,7 +32,10 @@ export function configure(opts: GlobalOpts): void {
32
32
  // Check for local/file-based mode
33
33
  if (process.env.TRICKLE_LOCAL === '1') {
34
34
  localMode = true;
35
- const dir = process.env.TRICKLE_LOCAL_DIR || pathMod.join(process.cwd(), '.trickle');
35
+ // Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
36
+ const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
37
+ const defaultDir = isLambda ? '/tmp/.trickle' : pathMod.join(process.cwd(), '.trickle');
38
+ const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
36
39
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
37
40
  localFilePath = pathMod.join(dir, 'observations.jsonl');
38
41
  if (debug) {
@@ -40,6 +40,13 @@ describe('React file detection', () => {
40
40
  }
41
41
  });
42
42
 
43
+ it('tracks export default function components', () => {
44
+ const code = `export default function HomeScreen() { return null; }`;
45
+ const out = transformTsx(code);
46
+ assert.ok(out, 'should transform');
47
+ assert.ok(out!.includes('__trickle_rc'), 'export default function should be tracked as component');
48
+ });
49
+
43
50
  it('does not track lowercase functions as components', () => {
44
51
  const code = `function helper(x) { return x + 1; }`;
45
52
  const out = transformTsx(code);
@@ -481,7 +481,7 @@ function escapeRegexStr(str: string): string {
481
481
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
482
482
  }
483
483
 
484
- function transformEsmSource(
484
+ export function transformEsmSource(
485
485
  source: string,
486
486
  filename: string,
487
487
  moduleName: string,
@@ -493,8 +493,8 @@ function transformEsmSource(
493
493
  // Detect React files for component render tracking
494
494
  const isReactFile = /\.(tsx|jsx)$/.test(filename);
495
495
 
496
- // Match top-level and nested function declarations (including async, export)
497
- const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
496
+ // Match top-level and nested function declarations (including async, export, export default)
497
+ const funcRegex = /^[ \t]*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
498
498
  const funcInsertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
499
499
  // Body insertions: insert at start of function body (for React render tracking)
500
500
  // propsExpr: JS expression to evaluate as the props object at render time