@apm-js-collab/code-transformer 0.10.0 → 0.11.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.
@@ -0,0 +1,459 @@
1
+ 'use strict'
2
+
3
+ const esquery = require('esquery')
4
+ const { parse } = require('meriyah')
5
+
6
+ /**
7
+ * Returns `true` if `node` is already a `tracingChannel` import/require statement,
8
+ * used to avoid duplicate injections.
9
+ *
10
+ * @param {import('estree').Node} node
11
+ * @returns {boolean}
12
+ */
13
+ const tracingChannelPredicate = (node) => (
14
+ node.declarations?.[0]?.id?.properties?.[0]?.value?.name === 'tr_ch_apm_tracingChannel'
15
+ )
16
+
17
+ const CHANNEL_REGEX = /[^\w]/g
18
+ /**
19
+ * Formats the channel variable name by replacing any non-whitespace characters with `_`
20
+ *
21
+ * @param {string} channelName
22
+ */
23
+ const formatChannelVariable = (channelName) => `tr_ch_apm$${channelName.replace(CHANNEL_REGEX, '_')}`
24
+
25
+ const transforms = module.exports = {
26
+ /**
27
+ * Injects a `tracingChannel` import/require into the program body if one is not
28
+ * already present.
29
+ *
30
+ * @param {{ dcModule: string, sourceType: 'module'|'script' }} state
31
+ * @param {import('estree').Program} node - The program root node.
32
+ */
33
+ tracingChannelImport ({ dcModule, moduleType }, node) {
34
+ if (node.body.some(tracingChannelPredicate)) return
35
+
36
+ const options = { module: moduleType === 'esm' }
37
+ const index = node.body.findIndex(child => child.directive === 'use strict')
38
+ const dc = moduleType === 'esm'
39
+ ? `import tr_ch_apm_dc from "${dcModule}"`
40
+ : `const tr_ch_apm_dc = require("${dcModule}")`
41
+ const tracingChannel = 'const { tracingChannel: tr_ch_apm_tracingChannel } = tr_ch_apm_dc'
42
+ const hasSubscribers = `const tr_ch_apm_hasSubscribers = ch => ch.start.hasSubscribers
43
+ || ch.end.hasSubscribers
44
+ || ch.asyncStart.hasSubscribers
45
+ || ch.asyncEnd.hasSubscribers
46
+ || ch.error.hasSubscribers`
47
+
48
+ node.body.splice(
49
+ index + 1,
50
+ 0,
51
+ parse(dc, options).body[0],
52
+ parse(tracingChannel, options).body[0],
53
+ parse(hasSubscribers, options).body[0]
54
+ )
55
+ },
56
+
57
+ /**
58
+ * Injects a `tracingChannel(...)` variable declaration for the config's channel
59
+ * into the program body, also ensuring the import is present.
60
+ *
61
+ * @param {{ channelName: string, module: { name: string }, dcModule: string, sourceType: 'module'|'script' }} state
62
+ * @param {import('estree').Program} node - The program root node.
63
+ */
64
+ tracingChannelDeclaration (state, node) {
65
+ const { channelName, module: { name } } = state
66
+ const channelVariable = formatChannelVariable(channelName)
67
+
68
+ if (node.body.some(child => child.declarations?.[0]?.id?.name === channelVariable)) return
69
+
70
+ transforms.tracingChannelImport(state, node)
71
+
72
+ const index = node.body.findIndex(tracingChannelPredicate)
73
+ const code = `
74
+ const ${channelVariable} = tr_ch_apm_tracingChannel("orchestrion:${name}:${channelName}")
75
+ `
76
+
77
+ node.body.splice(index + 1, 0, parse(code).body[0])
78
+ },
79
+
80
+ traceCallback: traceAny,
81
+ tracePromise: traceAny,
82
+ traceSync: traceAny,
83
+ }
84
+
85
+ /**
86
+ * Generic trace entry point. Dispatches to {@link traceInstanceMethod} for class
87
+ * nodes or {@link traceFunction} for all other function nodes.
88
+ *
89
+ * @param {object} state - Merged instrumentation + runtime state.
90
+ * @param {import('estree').Node} node - Matched AST node.
91
+ * @param {import('estree').Node} _parent
92
+ * @param {import('estree').Node[]} ancestry - Full ancestor chain, root last.
93
+ */
94
+ function traceAny (state, node, _parent, ancestry) {
95
+ const program = ancestry[ancestry.length - 1]
96
+
97
+ if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
98
+ traceInstanceMethod(state, node, program)
99
+ } else {
100
+ traceFunction(state, node, program)
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Wraps a function node's body with diagnostics_channel tracing.
106
+ *
107
+ * Injects the channel declaration, wraps the original body in an inner function,
108
+ * and rewrites `super` references if needed.
109
+ *
110
+ * @param {object} state
111
+ * @param {import('estree').Function} node - The function node to instrument.
112
+ * @param {import('estree').Program} program
113
+ */
114
+ function traceFunction (state, node, program) {
115
+ transforms.tracingChannelDeclaration(state, program)
116
+
117
+ const { functionQuery: { methodName, privateMethodName, functionName, expressionName } } = state
118
+ const isConstructor = methodName === 'constructor' ||
119
+ (!methodName && !privateMethodName && !functionName && !expressionName)
120
+ const type = isConstructor ? 'ArrowFunctionExpression' : 'FunctionExpression'
121
+
122
+ node.body = wrap(state, {
123
+ type,
124
+ params: node.params,
125
+ body: node.body,
126
+ async: node.async,
127
+ expression: false,
128
+ generator: node.generator,
129
+ }, program)
130
+
131
+ // The original function no longer contains any calls to `await` or `yield` as
132
+ // the function body is copied to the internal wrapped function, so we set
133
+ // these to false to avoid altering the return value of the wrapper. The old
134
+ // values are instead copied to the new AST node above.
135
+ node.generator = false
136
+ node.async = false
137
+
138
+ wrapSuper(state, node)
139
+ }
140
+
141
+ /**
142
+ * Instruments an instance method that may not exist on the class at definition
143
+ * time by patching it inside the constructor.
144
+ *
145
+ * If the method is already defined on the class body it is skipped (it will be
146
+ * handled via {@link traceFunction} when the child node is visited). Otherwise a
147
+ * constructor is synthesised (or extended) to wrap `this[methodName]` at runtime.
148
+ *
149
+ * @param {object} state
150
+ * @param {import('estree').ClassDeclaration|import('estree').ClassExpression} node
151
+ * @param {import('estree').Program} program
152
+ */
153
+ function traceInstanceMethod (state, node, program) {
154
+ const { functionQuery, operator } = state
155
+ const { methodName } = functionQuery
156
+
157
+ // No methodName means a constructor-only config — the constructor FunctionExpression
158
+ // is matched directly by the other queries and handled via traceFunction instead.
159
+ if (!methodName) return
160
+
161
+ const classBody = node.body
162
+
163
+ // If the method exists on the class, we return as it will be patched later
164
+ // while traversing child nodes later on.
165
+ if (classBody.body.some(({ key }) => key.name === methodName)) return
166
+
167
+ // Method doesn't exist on the class so we assume an instance method and
168
+ // wrap it in the constructor instead.
169
+ let ctor = classBody.body.find(({ kind }) => kind === 'constructor')
170
+
171
+ transforms.tracingChannelDeclaration(state, program)
172
+
173
+ if (!ctor) {
174
+ ctor = parse(
175
+ node.superClass
176
+ ? 'class A extends Object { constructor (...args) { super(...args) } }'
177
+ : 'class A { constructor () {} }'
178
+ ).body[0].body.body[0] // Extract constructor from dummy class body.
179
+
180
+ classBody.body.unshift(ctor)
181
+ }
182
+
183
+ const ctorBody = parse(`
184
+ const __apm$${methodName} = this["${methodName}"]
185
+ this["${methodName}"] = function () {}
186
+ `).body
187
+
188
+ // Extract only right-hand side function of line 2.
189
+ const fn = ctorBody[1].expression.right
190
+
191
+ fn.async = operator === 'tracePromise'
192
+ fn.body = wrap(state, { type: 'Identifier', name: `__apm$${methodName}` }, program)
193
+
194
+ wrapSuper(state, fn)
195
+
196
+ ctor.value.body.body.push(...ctorBody)
197
+ }
198
+
199
+ /**
200
+ * Builds the replacement block statement for a function body.
201
+ *
202
+ * Selects the appropriate wrapper template (`wrapSync`, `wrapPromise`, etc.) and
203
+ * prepends the shared `__apm$ctx` / `__apm$traced` preamble before returning the
204
+ * resulting block statement body.
205
+ *
206
+ * @param {object} state
207
+ * @param {import('estree').Node} node - The original function (or identifier for instance methods).
208
+ * @param {import('estree').Program} program
209
+ * @returns {import('estree').BlockStatement['body']}
210
+ */
211
+ function wrap (state, node, program) {
212
+ const { operator, moduleVersion } = state
213
+
214
+ let wrapper
215
+
216
+ if (operator === 'traceCallback') wrapper = wrapCallback(state, node)
217
+ if (operator === 'tracePromise') wrapper = wrapPromise(state, node, program)
218
+ if (operator === 'traceSync') wrapper = wrapSync(state, node, program)
219
+
220
+ const block = wrapper.body[0].body // Extract only block statement of function body.
221
+ const common = parse(node.type === 'ArrowFunctionExpression'
222
+ ? `
223
+ const __apm$ctx = {
224
+ arguments,
225
+ moduleVersion: ${JSON.stringify(moduleVersion)}
226
+ };
227
+ const __apm$traced = () => {
228
+ const __apm$wrapped = () => {};
229
+ return __apm$wrapped(...arguments);
230
+ };
231
+ `
232
+ : `
233
+ const __apm$ctx = {
234
+ arguments,
235
+ self: this,
236
+ moduleVersion: ${JSON.stringify(moduleVersion)}
237
+ };
238
+ const __apm$traced = () => {
239
+ const __apm$wrapped = () => {};
240
+ return __apm$wrapped.apply(this, arguments);
241
+ };
242
+ `).body
243
+
244
+ block.body.unshift(...common)
245
+
246
+ // Replace the right-hand side assignment of `const __apm$wrapped = () => {}`.
247
+ esquery.query(block, '[id.name=__apm$wrapped]')[0].init = node
248
+
249
+ return block
250
+ }
251
+
252
+ /**
253
+ * Rewrites `super.method(...)` calls inside a moved function body.
254
+ *
255
+ * Because the original body is copied into a nested arrow/function, `super` would
256
+ * no longer be in scope. Each unique `super.x` reference is replaced with
257
+ * `__apm$super['x']` and a preamble that captures the super-binding in the
258
+ * outermost method scope is prepended.
259
+ *
260
+ * @param {object} _state
261
+ * @param {import('estree').Function} node - The outer (wrapper) function node.
262
+ */
263
+ function wrapSuper (_state, node) {
264
+ const members = new Set()
265
+
266
+ esquery.traverse(
267
+ node.body,
268
+ esquery.parse('[object.type=Super]'),
269
+ (node, parent) => {
270
+ const { name } = node.property
271
+
272
+ let child
273
+
274
+ if (parent.callee) {
275
+ // This is needed because for generator functions we have to move the
276
+ // original function to a nested wrapped function, but we can't use an
277
+ // arrow function because arrow function cannot be generator functions,
278
+ // and `super` cannot be called from a nested function, so we have to
279
+ // rewrite any `super` call to not use the keyword.
280
+ const { expression } = parse(`__apm$super['${name}'].call(this)`).body[0]
281
+
282
+ parent.callee = child = expression.callee
283
+ parent.arguments.unshift(...expression.arguments)
284
+ } else {
285
+ parent.expression = child = parse(`__apm$super['${name}']`).body[0]
286
+ }
287
+
288
+ child.computed = parent.callee.computed
289
+ child.optional = parent.callee.optional
290
+
291
+ members.add(name)
292
+ }
293
+ )
294
+
295
+ for (const name of members) {
296
+ const member = parse(`
297
+ class Wrapper {
298
+ wrapper () {
299
+ __apm$super['${name}'] = super['${name}']
300
+ }
301
+ }
302
+ `).body[0].body.body[0].value.body.body[0]
303
+
304
+ node.body.body.unshift(member)
305
+ }
306
+
307
+ if (members.size > 0) {
308
+ node.body.body.unshift(parse('const __apm$super = {}').body[0])
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Builds the wrapper AST for a callback-style function.
314
+ *
315
+ * Replaces the callback argument (at `callbackIndex`) with a wrapped version that
316
+ * publishes to the tracing channel on completion or error.
317
+ *
318
+ * @param {{ channelName: string, functionQuery: { callbackIndex?: number } }} state
319
+ * @param {import('estree').Node} node - The original function node (unused; replaced by preamble).
320
+ * @returns {import('estree').Program} Parsed wrapper function program.
321
+ */
322
+ function wrapCallback (state, node) {
323
+ const { channelName, functionQuery: { callbackIndex = -1 } } = state
324
+ const channelVariable = formatChannelVariable(channelName)
325
+
326
+ return parse(`
327
+ function wrapper () {
328
+ const __apm$cb = Array.prototype.at.call(arguments, ${callbackIndex});
329
+
330
+ if (!${channelVariable}.start.hasSubscribers) return __apm$traced();
331
+
332
+ function __apm$wrappedCb(err, res) {
333
+ if (err) {
334
+ __apm$ctx.error = err;
335
+ ${channelVariable}.error.publish(__apm$ctx);
336
+ } else {
337
+ __apm$ctx.result = res;
338
+ }
339
+
340
+ ${channelVariable}.asyncStart.runStores(__apm$ctx, () => {
341
+ try {
342
+ if (__apm$cb) {
343
+ return __apm$cb.apply(this, arguments);
344
+ }
345
+ } finally {
346
+ ${channelVariable}.asyncEnd.publish(__apm$ctx);
347
+ }
348
+ });
349
+ }
350
+
351
+ if (typeof __apm$cb !== 'function') {
352
+ return __apm$traced();
353
+ }
354
+ Array.prototype.splice.call(arguments, ${callbackIndex}, 1, __apm$wrappedCb);
355
+
356
+ return ${channelVariable}.start.runStores(__apm$ctx, () => {
357
+ try {
358
+ return __apm$traced();
359
+ } catch (err) {
360
+ __apm$ctx.error = err;
361
+ ${channelVariable}.error.publish(__apm$ctx);
362
+ throw err;
363
+ } finally {
364
+ __apm$ctx.self ??= this;
365
+ ${channelVariable}.end.publish(__apm$ctx);
366
+ }
367
+ });
368
+ }
369
+ `)
370
+ }
371
+
372
+ /**
373
+ * Builds the wrapper AST for a Promise-returning function.
374
+ *
375
+ * Uses `runStores` to propagate async context and publishes
376
+ * `asyncStart`/`asyncEnd`/`error` channel events on settlement.
377
+ *
378
+ * @param {{ channelName: string }} state
379
+ * @param {import('estree').Node} node
380
+ * @returns {import('estree').Program} Parsed wrapper function program.
381
+ */
382
+ function wrapPromise (state, node) {
383
+ const { channelName } = state
384
+ const channelVariable = formatChannelVariable(channelName)
385
+
386
+ return parse(`
387
+ function wrapper () {
388
+ if (!tr_ch_apm_hasSubscribers(${channelVariable})) return __apm$traced();
389
+
390
+ return ${channelVariable}.start.runStores(__apm$ctx, () => {
391
+ try {
392
+ let promise = __apm$traced();
393
+ if (typeof promise?.then !== 'function') {
394
+ __apm$ctx.result = promise;
395
+ return promise;
396
+ }
397
+ return promise.then(
398
+ result => {
399
+ __apm$ctx.result = result;
400
+ ${channelVariable}.asyncStart.publish(__apm$ctx);
401
+ ${channelVariable}.asyncEnd.publish(__apm$ctx);
402
+ return result;
403
+ },
404
+ err => {
405
+ __apm$ctx.error = err;
406
+ ${channelVariable}.error.publish(__apm$ctx);
407
+ ${channelVariable}.asyncStart.publish(__apm$ctx);
408
+ ${channelVariable}.asyncEnd.publish(__apm$ctx);
409
+ throw err;
410
+ }
411
+ );
412
+ } catch (err) {
413
+ __apm$ctx.error = err;
414
+ ${channelVariable}.error.publish(__apm$ctx);
415
+ throw err;
416
+ } finally {
417
+ __apm$ctx.self ??= this;
418
+ ${channelVariable}.end.publish(__apm$ctx);
419
+ }
420
+ });
421
+ }
422
+ `)
423
+ }
424
+
425
+ /**
426
+ * Builds the wrapper AST for a synchronous function.
427
+ *
428
+ * Uses `runStores` for context propagation and publishes `error` channel events
429
+ * on throw. The result is stored in `__apm$ctx.result` on success.
430
+ *
431
+ * @param {{ channelName: string }} state
432
+ * @param {import('estree').Node} node
433
+ * @returns {import('estree').Program} Parsed wrapper function program.
434
+ */
435
+ function wrapSync (state, node) {
436
+ const { channelName } = state
437
+ const channelVariable = formatChannelVariable(channelName)
438
+
439
+ return parse(`
440
+ function wrapper () {
441
+ if (!tr_ch_apm_hasSubscribers(${channelVariable})) return __apm$traced();
442
+
443
+ return ${channelVariable}.start.runStores(__apm$ctx, () => {
444
+ try {
445
+ const result = __apm$traced();
446
+ __apm$ctx.result = result;
447
+ return result;
448
+ } catch (err) {
449
+ __apm$ctx.error = err;
450
+ ${channelVariable}.error.publish(__apm$ctx);
451
+ throw err;
452
+ } finally {
453
+ __apm$ctx.self ??= this;
454
+ ${channelVariable}.end.publish(__apm$ctx);
455
+ }
456
+ });
457
+ }
458
+ `)
459
+ }
package/package.json CHANGED
@@ -1,37 +1,41 @@
1
1
  {
2
2
  "name": "@apm-js-collab/code-transformer",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/nodejs/orchestrion-js.git"
8
8
  },
9
9
  "files": [
10
- "./pkg/orchestrion_js.js",
11
- "./pkg/orchestrion_js.d.ts",
12
10
  "./index.js",
13
11
  "./index.d.ts",
12
+ "./lib/**/*.js",
14
13
  "LICENSE",
15
14
  "NOTICE"
16
15
  ],
17
16
  "main": "./index.js",
18
17
  "types": "./index.d.ts",
19
18
  "scripts": {
20
- "build": "wasm-pack build --target nodejs --release -- --features wasm && yarn build:wrapper && yarn build:inline-binary",
21
- "build:wrapper": "tsc index.ts --declaration --module commonjs",
22
- "build:inline-binary": "node inline-binary.js",
23
- "test": "vitest run",
24
- "test:update-snapshots": "vitest -u run",
25
- "test:watch": "vitest"
19
+ "lint": "eslint .",
20
+ "lint:fix": "eslint --fix .",
21
+ "test": "node --test tests/tests.test.mjs",
22
+ "test:watch": "node --watch --test tests/tests.test.mjs"
26
23
  },
27
24
  "devDependencies": {
28
25
  "@types/node": "^18.0.0",
29
- "source-map": "^0.7.6",
30
- "typescript": "^5.8.3",
31
- "vitest": "^3.2.4",
32
- "wasm-pack": "^0.13.1"
26
+ "eslint": "^9.39.4",
27
+ "neostandard": "^0.13.0",
28
+ "typescript": "^5.8.3"
33
29
  },
34
30
  "volta": {
35
31
  "node": "22.15.0"
32
+ },
33
+ "dependencies": {
34
+ "@types/estree": "^1.0.8",
35
+ "astring": "^1.9.0",
36
+ "esquery": "^1.7.0",
37
+ "meriyah": "^6.1.4",
38
+ "semifies": "^1.0.0",
39
+ "source-map": "^0.6.0"
36
40
  }
37
41
  }
@@ -1,97 +0,0 @@
1
- /* tslint:disable */
2
- /* eslint-disable */
3
- /**
4
- * Create a new instrumentation matcher from an array of instrumentation configs.
5
- */
6
- export function create(configs: InstrumentationConfig[], dc_module?: string | null): InstrumentationMatcher;
7
- /**
8
- * Output of a transformation operation
9
- */
10
- export interface TransformOutput {
11
- /**
12
- * The transformed JavaScript code
13
- */
14
- code: string;
15
- /**
16
- * The sourcemap for the transformation (if generated)
17
- */
18
- map: string | undefined;
19
- }
20
-
21
- /**
22
- * Describes which function to instrument
23
- */
24
- export type FunctionQuery = { className: string; methodName: string; kind: FunctionKind; index?: number; isExportAlias?: boolean } | { className: string; privateMethodName: string; kind: FunctionKind; index?: number } | { className: string; index?: number; isExportAlias?: boolean } | { methodName: string; kind: FunctionKind; index?: number } | { functionName: string; kind: FunctionKind; index?: number; isExportAlias?: boolean } | { expressionName: string; kind: FunctionKind; index?: number; isExportAlias?: boolean };
25
-
26
- /**
27
- * The kind of function - Sync or returns a promise
28
- */
29
- export type FunctionKind = "Sync" | "Async";
30
-
31
- /**
32
- * Configuration for injecting instrumentation code
33
- */
34
- export interface InstrumentationConfig {
35
- /**
36
- * The name of the diagnostics channel to publish to
37
- */
38
- channelName: string;
39
- /**
40
- * The module matcher to identify the module and file to instrument
41
- */
42
- module: ModuleMatcher;
43
- /**
44
- * The function query to identify the function to instrument
45
- */
46
- functionQuery: FunctionQuery;
47
- }
48
-
49
- /**
50
- * Describes the module and file path you would like to match
51
- */
52
- export interface ModuleMatcher {
53
- /**
54
- * The name of the module you want to match
55
- */
56
- name: string;
57
- /**
58
- * The semver range that you want to match
59
- */
60
- versionRange: string;
61
- /**
62
- * The path of the file you want to match from the module root
63
- */
64
- filePath: string;
65
- }
66
-
67
- /**
68
- * The type of module being passed - ESM, CJS or unknown
69
- */
70
- export type ModuleType = "esm" | "cjs" | "unknown";
71
-
72
- /**
73
- * The InstrumentationMatcher is responsible for matching specific modules
74
- */
75
- export class InstrumentationMatcher {
76
- private constructor();
77
- free(): void;
78
- /**
79
- * Get a transformer for the given module name, version and file path.
80
- * Returns `undefined` if no matching instrumentations are found.
81
- */
82
- getTransformer(module_name: string, version: string, file_path: string): Transformer | undefined;
83
- }
84
- /**
85
- * The Transformer is responsible for transforming JavaScript code.
86
- */
87
- export class Transformer {
88
- private constructor();
89
- free(): void;
90
- /**
91
- * Transform JavaScript code and optionally sourcemap.
92
- *
93
- * # Errors
94
- * Returns an error if the transformation fails to find injection points.
95
- */
96
- transform(code: string, module_type: ModuleType, sourcemap?: string | null): TransformOutput;
97
- }