diff --git a/README.md b/README.md index e474140..deec005 100644 --- a/README.md +++ b/README.md @@ -156,21 +156,27 @@ $ bun run --import=./plugin.ts app.ts | `injectDiagnostics` | `(diagnostics) => string?` | Called after the build with `{ transformedModules, failedModules }`; the returned code is prepended to every entry point bundle. The code is injected after bundling, so it must not contain `import`/`require`. | | `transformFilter` | `TransformIdFilter \| false` | Restricts which module ids the transform hook runs on (default `/node_modules/`). Supported by bundlers with hook filters (Rollup ≥ 4.38, Rolldown, Vite). | | `customTransforms` | `Record` | Custom transforms registered on the matcher via orchestrion's `addTransform`. See below. | +| `loaderPath` | `string?` | Webpack only. The loader to run instead of this package's own, for wrapping it with transforms bound at require time. See below. | +| `cacheVersion` | `string?` | Webpack only. Folded into the loader's cache key. Bump it when a transform's behaviour changes in a way its source text does not show. See below. | ## Custom transforms: injecting code into instrumented files -An `InstrumentationConfig` can name a custom transform in its `transform` -field. The function is called for every AST node matched by that config's -`functionQuery`/`astQuery` as `(state, node, parent, ancestry)`, where `state` -is the matched config spread together with -`{ dcModule, moduleType, moduleVersion }`. - -This can be used to inject code — including `import`/`require` statements — -into the files being instrumented. Because the injection happens during the -transform, the bundler resolves and bundles whatever the injected code -imports, and the code is only included when the instrumented package is -actually part of the build. A single transform can serve every injection site -by branching on `state.module.name`: +`customTransforms` registers transforms on the matcher under a name. A name +that an `InstrumentationConfig` opts into through its `transform` field runs +for the nodes that config matches; a name that collides with one of +orchestrion's built-ins overrides that built-in everywhere, including where +orchestrion calls it internally. Either way the function receives +`(state, node, parent, ancestry)`, where `state` is the matched config spread +together with `{ dcModule, moduleType, moduleVersion, transforms }`. + +Overriding `tracingChannelImport` is the way to inject code — including +`import`/`require` statements — into the files being instrumented. Orchestrion +calls it when it sets up a file's diagnostics channel, so it runs exactly when +a function was really wrapped. Because the injection happens during the +transform, the bundler resolves and bundles whatever the injected code imports, +and the code is only included when the instrumented package is actually part of +the build. A single transform can serve every injection site by branching on +`state.module.name`: ```javascript import { parse } from "meriyah"; @@ -181,59 +187,165 @@ const INTEGRATIONS = { subscribeToMysql();`, }; +// Marks the statement orchestrion's built-in adds, which is both what tells us +// the import is in place and where we want our own code to go. +const isTracingChannelImport = (node) => + node.declarations?.[0]?.id?.properties?.[0]?.value?.name === + "tr_ch_apm_tracingChannel"; + // One transform handles every injection site; `state` identifies the site. function injectIntegration(state, program) { - const { module: { name }, moduleType } = state; - const snippet = INTEGRATIONS[name]; + // Run the built-in first: it adds the diagnostics_channel import our code is + // placed after, and orchestrion still needs it to declare the channel. + state.transforms.defaults.tracingChannelImport(state, program); + + const snippet = INTEGRATIONS[state.module.name]; if (!snippet) return; - // A file can be matched by several configs; only inject once. + // Called once per channel, so a file with several instrumented functions + // arrives here more than once. if (program.__integrationInjected) return; program.__integrationInjected = true; - const statements = parse(snippet, { module: moduleType === "esm" }).body; - // Insert after any "use strict" directive, like orchestrion's built-ins. - const index = program.body.findIndex((node) => node.directive === "use strict"); - program.body.splice(index + 1, 0, ...statements); + const statements = parse(snippet, { module: state.moduleType === "esm" }).body; + program.body.splice( + program.body.findIndex(isTracingChannelImport) + 1, + 0, + ...statements, + ); } -const mysqlMatcher = { - name: "mysql", - versionRange: ">=2.0.0", - filePath: "lib/connection.js", -}; - codeTransformer({ instrumentations: [ - // The real instrumentation { channelName: "mysql:query", - module: mysqlMatcher, + module: { + name: "mysql", + versionRange: ">=2.0.0", + filePath: "lib/connection.js", + }, functionQuery: { methodName: "query", kind: "Callback" }, }, - // The injection site: same module matcher, Program node, custom transform - { - channelName: "integration-injection", - module: mysqlMatcher, - astQuery: "Program", - transform: "injectIntegration", - }, ], - customTransforms: { injectIntegration }, + // Overrides orchestrion's built-in of the same name. + customTransforms: { tracingChannelImport: injectIntegration }, }); ``` Things to be aware of: -- A `Program` config matches whenever the *file* matches the module matcher, - so the injection also happens if a sibling function query found nothing in - that file. To gate on "a function was actually wrapped", order the injection - config last and check the program for orchestrion's channel setup: - `program.body.some((n) => n.declarations?.[0]?.id?.properties?.[0]?.value?.name === "tr_ch_apm_tracingChannel")`. -- Because the always-matching `Program` config counts as an injection point, - orchestrion's "Failed to find injection points" error is suppressed for that - file, so such modules will not appear in `injectDiagnostics`'s +- The override replaces the built-in, so it has to call the original. Skipping + it leaves the file without its `diagnostics_channel` import, and the channel + declaration orchestrion appends next will reference an undefined variable. +- Orchestrion invokes it once per channel rather than once per file, so a file + with several instrumented functions needs the dedupe flag above. The built-in + is idempotent and can be called every time. +- Nothing is injected into a file whose instrumentation found no functions to + wrap, which is the point of overriding this transform rather than adding a + `Program` config that matches every file the module matcher does. Such a file + still fails as it normally would, and still appears in `injectDiagnostics`'s `failedModules`. +- This requires `@apm-js-collab/code-transformer` >= 0.18.1, where internal + calls to built-in transforms dispatch through the override map and + `state.transforms.defaults` exposes the originals. - Custom transforms mutate ESTree nodes. Parse code snippets with [`meriyah`](https://github.com/meriyah/meriyah) (orchestrion's own parser) so the resulting AST round-trips through code generation. +- Under webpack, a transform that reads data it does not name — a captured + variable, or a module-scope table of snippets — needs `cacheVersion` to + invalidate a filesystem cache. See below. + +### Custom transforms with Webpack + +`customTransforms` works with the webpack plugin as it does everywhere else. +The plugin instruments through a loader, and webpack hands loader options to +the loader by reference, so the functions arrive intact. + +One thing to know if you use `cache: { type: 'filesystem' }`. Webpack keys a +loader by its ruleset ident rather than by the contents of its options, so a +changed config would ordinarily go unnoticed and cached modules would be reused. +The plugin therefore derives the ident from the config itself: the +instrumentations, `dcModule`, and the source text of every custom transform. +Editing any of those rebuilds the affected modules. + +What the ident cannot see is data a transform reads without naming it, because +`Function.prototype.toString` does not capture it: + +```javascript +const INTEGRATIONS = { mysql: "..." }; // editing this does not change the source + +function injectIntegration(state, program) { + const snippet = INTEGRATIONS[state.module.name]; + // ... +} +``` + +Set `cacheVersion` when that data changes — derived from the data itself, or +from your package's version: + +```javascript +codeTransformer({ + instrumentations: [ + /* ... */ + ], + customTransforms: { tracingChannelImport: injectIntegration }, + cacheVersion: require("./package.json").version, +}); +``` + +### Custom transforms with Turbopack + +Turbopack serializes loader options as JSON, so functions cannot reach the +loader through them; the same applies to loaders that run in worker processes, +such as `thread-loader`. For those, ship a loader of your own with the +transforms already bound. They then live in that module's scope inside the +loader process and never cross a serialization boundary — only the JSON-safe +`instrumentations` do. Webpack also tracks the loader file's own contents, so +editing a transform there invalidates cached modules without `cacheVersion`. + +```javascript +// my-library/loader.cjs +const { + createLoader, +} = require("@apm-js-collab/code-transformer-bundler-plugins/webpack-loader-factory"); +const { injectIntegration } = require("./transforms.cjs"); + +module.exports = createLoader({ + customTransforms: { tracingChannelImport: injectIntegration }, + // Optional: bake in the instrumentations too, so callers pass no options at + // all. Per-rule loader options override these when present. + // instrumentations: [...], +}); +``` + +Point webpack at it with the plugin's `loaderPath`, which keeps +`injectDiagnostics` working: + +```javascript +codeTransformer({ + loaderPath: require.resolve("my-library/loader.cjs"), + instrumentations: [ + /* ... */ + ], +}); +``` + +Or register it directly, which is what Turbopack needs: + +```javascript +// next.config.js +module.exports = { + turbopack: { + rules: { + "**/*.{js,cjs,mjs}": { + loaders: [ + { + loader: "my-library/loader.cjs", + options: { instrumentations: serializeInstrumentations(configs) }, + }, + ], + }, + }, + }, +}; +``` diff --git a/package.json b/package.json index b8b8219..565f3ee 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,16 @@ "./webpack-loader": { "types": "./dist/cjs/webpack-loader.d.ts", "default": "./dist/cjs/webpack-loader.cjs" + }, + "./webpack-loader-factory": { + "import": { + "types": "./dist/esm/webpack-loader-factory.d.ts", + "default": "./dist/esm/webpack-loader-factory.mjs" + }, + "require": { + "types": "./dist/cjs/webpack-loader-factory.d.ts", + "default": "./dist/cjs/webpack-loader-factory.cjs" + } } }, "typesVersions": { @@ -108,6 +118,9 @@ ], "webpack-loader": [ "./dist/esm/webpack-loader.d.ts" + ], + "webpack-loader-factory": [ + "./dist/esm/webpack-loader-factory.d.ts" ] } }, @@ -118,7 +131,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer": "^0.18.0", + "@apm-js-collab/code-transformer": "^0.18.1", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" diff --git a/src/webpack-loader-factory.ts b/src/webpack-loader-factory.ts new file mode 100644 index 0000000..c8679a5 --- /dev/null +++ b/src/webpack-loader-factory.ts @@ -0,0 +1,260 @@ +import { create, ModuleType, type CustomTransform } from '@apm-js-collab/code-transformer'; +import { join, extname } from 'path'; +import { readFileSync } from 'fs'; +import * as moduleDetailsFromPathImport from 'module-details-from-path'; +import { + deserializeInstrumentations, + serializeInstrumentations, + type AnyInstrumentationConfig, +} from './instrumentation-serde.js'; + +// Handle CJS default export - module-details-from-path exports a function directly +const moduleDetailsFromPath = (moduleDetailsFromPathImport as any).default || moduleDetailsFromPathImport as any; + +const DIAGNOSTICS_STATE_KEY = '__codeTransformerWebpackDiagnostics'; + +type DiagnosticsState = { + transformedModules: Set; + failedModules: Set; +}; + +/** + * Helper function to get module version from package.json + */ +function getModuleVersion(basedir: string): string | undefined { + try { + const packageJsonPath = join(basedir, 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + if (packageJson.version) { + return packageJson.version; + } + } catch (error) { + // + } + + return undefined; // No version found +} + +function getDiagnosticsState(loaderContext: any): DiagnosticsState | undefined { + return loaderContext?._compilation?.[DIAGNOSTICS_STATE_KEY]; +} + +/** + * The per-rule options a loader reads from `this.getOptions()`. Every field is + * optional here because {@link createLoader} can supply it instead; the + * loader still needs `instrumentations` from one of the two sources. + * + * Turbopack requires loader options to be JSON-serializable, so any `RegExp` + * in `module.filePath` must be passed in its serialized `{ source, flags }` + * form there — see `serializeInstrumentations` in the `/core` export. + */ +export interface LoaderOptions { + /** Array of instrumentation configurations */ + instrumentations?: AnyInstrumentationConfig[]; + /** Optional path to a polyfill module for diagnostics_channel */ + dcModule?: string; + /** + * Custom transforms registered on the matcher via orchestrion's + * `addTransform`. An `InstrumentationConfig` opts in by naming one of these + * in its `transform` field. + * + * Webpack passes loader options through by reference, so functions arrive + * intact. Turbopack does not — it serializes them as JSON — and neither do + * loaders that run in worker processes, such as `thread-loader`. For those, + * bind the transforms with {@link createLoader} instead. + */ + customTransforms?: Record; +} + +/** + * Baked-in configuration for a loader built with {@link createLoader}. These + * values live in the loader module's own scope, so unlike per-rule loader + * options they never cross a serialization boundary. + * + * Per-rule options take precedence over `instrumentations` and `dcModule` + * given here, and per-rule `customTransforms` are merged over these. + */ +export interface CreateLoaderOptions extends LoaderOptions {} + +/** + * Identity keys for transform functions, so that the matcher cache can tell two + * sets of custom transforms apart. Functions have no stable serialization — + * `toString` ignores captured variables — so identity is all we can key on. + */ +const transformIds = new WeakMap(); +let nextTransformId = 0; + +function customTransformsKey(customTransforms: Record): string { + return Object.keys(customTransforms) + .sort() + .map((name) => { + const fn = customTransforms[name]!; + let id = transformIds.get(fn); + + if (id === undefined) { + id = nextTransformId++; + transformIds.set(fn, id); + } + + return `${name}:${id}`; + }) + .join(','); +} + +/** A webpack-compatible loader function, as returned by {@link createLoader}. */ +export type CodeTransformerLoader = ( + this: any, + code: string, + inputSourceMap?: any, +) => void; + +/** + * Builds a webpack loader that instruments JavaScript code using + * code-transformer. + * + * Use this to wrap the loader in your own package when loader options are not + * a viable channel for custom transforms — under Turbopack, which serializes + * them as JSON, or with worker-based loaders such as `thread-loader`: + * + * ```js + * // my-library/loader.cjs + * const { createLoader } = require('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader-factory'); + * module.exports = createLoader({ customTransforms: { injectIntegration } }); + * ``` + * + * Webpack resolves that module by path, so the transform stays in the loader + * process and only the JSON-serializable `instrumentations` cross into the + * loader options. + * + * The plain `/webpack-loader` export is `createLoader()` with no baked-in + * configuration; the webpack plugin passes `customTransforms` to it directly. + */ +export function createLoader( + factoryOptions: CreateLoaderOptions = {}, +): CodeTransformerLoader { + // Scoped to this loader instance: two loaders sharing the module-level + // cache would collide whenever their instrumentations match but their + // custom transforms differ, since only the former is part of the key. + const matcherCache = new Map>(); + + /** + * Get or create a matcher instance with caching based on config hash + */ + function getMatcher( + instrumentations: AnyInstrumentationConfig[], + dcModule: string | undefined, + customTransforms: Record, + ) { + // Hash the serialized form: JSON.stringify turns a raw RegExp into `{}`, + // which would make configs differing only in their regex hash identically. + const configHash = JSON.stringify({ + instrumentations: serializeInstrumentations(instrumentations), + dcModule, + customTransforms: customTransformsKey(customTransforms), + }); + + if (matcherCache.has(configHash)) { + return matcherCache.get(configHash)!; + } + + // Free old matchers to prevent memory leaks + for (const [hash, matcher] of matcherCache.entries()) { + if (hash !== configHash) { + matcherCache.delete(hash); + } + } + + const matcher = create(deserializeInstrumentations(instrumentations), dcModule ?? null); + + for (const [name, fn] of Object.entries(customTransforms)) { + matcher.addTransform(name, fn); + } + + matcherCache.set(configHash, matcher); + return matcher; + } + + return function codeTransformerLoader( + this: any, + code: string, + inputSourceMap?: any, + ) { + const callback = this.async(); + const options: LoaderOptions = this.getOptions(); + const resourcePath: string = this.resourcePath; + + // Per-rule options win over whatever the loader was built with, and + // per-rule transforms are merged over the baked-in ones by name. + const instrumentations = options.instrumentations ?? factoryOptions.instrumentations; + const dcModule = options.dcModule ?? factoryOptions.dcModule; + const customTransforms = { + ...factoryOptions.customTransforms, + ...options.customTransforms, + }; + + if (!instrumentations || instrumentations.length === 0) { + return callback(null, code, inputSourceMap); + } + + // Determine if this is an ES module using multiple methods for accurate detection + const ext = extname(resourcePath); + let moduleType: ModuleType = + ext === '.mjs' || ext === '.ts' || ext === '.tsx' ? 'esm' : 'unknown'; + + // For .js files, use content analysis for module detection + if (ext === '.js') { + moduleType = code.includes('export ') || code.includes('import ') ? 'esm' : 'cjs'; + } else if (ext === '.cjs') { + moduleType = 'cjs'; + } + + // Try to get module details from the file path + const moduleDetails = moduleDetailsFromPath(resourcePath); + + // If no module details found, the file is not part of a module + if (!moduleDetails) { + return callback(null, code, inputSourceMap); + } + + // Use module details for accurate module information + const moduleName = moduleDetails.name; + const moduleVersion = getModuleVersion(moduleDetails.basedir); + + // If no version found + if (!moduleVersion) { + return callback(null, code, inputSourceMap); + } + + // Try to get a transformer for this file + const matcher = getMatcher(instrumentations, dcModule, customTransforms); + const transformer = matcher.getTransformer( + moduleName, + moduleVersion, + moduleDetails.path + ); + + if (!transformer) { + // No instrumentations match this file + return callback(null, code, inputSourceMap); + } + + try { + // Transform the code + const result = transformer.transform(code, moduleType, inputSourceMap); + const diagnosticsState = getDiagnosticsState(this); + + diagnosticsState?.transformedModules.add(transformer.moduleName); + + callback(null, result.code, result.map); + } catch (error) { + console.warn(`[code-transformer-loader] Error transforming ${resourcePath}:`, error); + const diagnosticsState = getDiagnosticsState(this); + + diagnosticsState?.failedModules.add(moduleDetails.name); + callback(null, code, inputSourceMap); + } + }; +} + +export type { CustomTransform } from '@apm-js-collab/code-transformer'; +export type { AnyInstrumentationConfig } from './instrumentation-serde.js'; diff --git a/src/webpack-loader.ts b/src/webpack-loader.ts index cd909f5..6b0a8cf 100644 --- a/src/webpack-loader.ts +++ b/src/webpack-loader.ts @@ -1,145 +1,26 @@ -import { create, ModuleType } from '@apm-js-collab/code-transformer'; -import { join, extname } from 'path'; -import { readFileSync } from 'fs'; -import * as moduleDetailsFromPathImport from 'module-details-from-path'; -import { - deserializeInstrumentations, - serializeInstrumentations, - type AnyInstrumentationConfig, -} from './instrumentation-serde.js'; +import { createLoader } from './webpack-loader-factory.js'; +import { type CustomTransform } from '@apm-js-collab/code-transformer'; +import { type AnyInstrumentationConfig } from './instrumentation-serde.js'; -// Handle CJS default export - module-details-from-path exports a function directly -const moduleDetailsFromPath = (moduleDetailsFromPathImport as any).default || moduleDetailsFromPathImport as any; - -/** - * Helper function to get module version from package.json - */ -function getModuleVersion(basedir: string): string | undefined { - try { - const packageJsonPath = join(basedir, 'package.json'); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - if (packageJson.version) { - return packageJson.version; - } - } catch (error) { - // - } - - return undefined; // No version found -} - -// Matcher cache with config hash for cache invalidation -const matcherCache = new Map>(); -const DIAGNOSTICS_STATE_KEY = '__codeTransformerWebpackDiagnostics'; - -type DiagnosticsState = { - transformedModules: Set; - failedModules: Set; -}; - -function getDiagnosticsState(loaderContext: any): DiagnosticsState | undefined { - return loaderContext?._compilation?.[DIAGNOSTICS_STATE_KEY]; -} - -/** - * Get or create a matcher instance with caching based on config hash - */ -function getMatcher(instrumentations: AnyInstrumentationConfig[], dcModule?: string) { - // Hash the serialized form: JSON.stringify turns a raw RegExp into `{}`, - // which would make configs differing only in their regex hash identically. - const configHash = JSON.stringify({ - instrumentations: serializeInstrumentations(instrumentations), - dcModule, - }); - - if (matcherCache.has(configHash)) { - return matcherCache.get(configHash)!; - } - - // Free old matchers to prevent memory leaks - for (const [hash, matcher] of matcherCache.entries()) { - if (hash !== configHash) { - matcherCache.delete(hash); - } - } - - const matcher = create(deserializeInstrumentations(instrumentations), dcModule ?? null); - matcherCache.set(configHash, matcher); - return matcher; -} +const loader = createLoader(); /** * Webpack loader that instruments JavaScript code using code-transformer * * This is a webpack loader (not a plugin) for compatibility with tools that only support loaders, * such as Next.js Turbopack. Unlike the other exports in this package, this does not use unplugin. + * + * Everything it needs arrives through loader options, which have to be + * JSON-serializable. To use custom transforms — which are functions, and so + * cannot — build your own loader module with `createLoader` from the + * `/webpack-loader-factory` export and point the bundler at that instead. */ function codeTransformerLoader( this: any, code: string, inputSourceMap?: any ) { - const callback = this.async(); - const options: codeTransformerLoader.Options = this.getOptions(); - const resourcePath: string = this.resourcePath; - - // Determine if this is an ES module using multiple methods for accurate detection - const ext = extname(resourcePath); - let moduleType: ModuleType = - ext === '.mjs' || ext === '.ts' || ext === '.tsx' ? 'esm' : 'unknown'; - - // For .js files, use content analysis for module detection - if (ext === '.js') { - moduleType = code.includes('export ') || code.includes('import ') ? 'esm' : 'cjs'; - } else if (ext === '.cjs') { - moduleType = 'cjs'; - } - - // Try to get module details from the file path - const moduleDetails = moduleDetailsFromPath(resourcePath); - - // If no module details found, the file is not part of a module - if (!moduleDetails) { - return callback(null, code, inputSourceMap); - } - - // Use module details for accurate module information - const moduleName = moduleDetails.name; - const moduleVersion = getModuleVersion(moduleDetails.basedir); - - // If no version found - if (!moduleVersion) { - return callback(null, code, inputSourceMap); - } - - // Try to get a transformer for this file - const matcher = getMatcher(options.instrumentations, options.dcModule); - const transformer = matcher.getTransformer( - moduleName, - moduleVersion, - moduleDetails.path - ); - - if (!transformer) { - // No instrumentations match this file - return callback(null, code, inputSourceMap); - } - - try { - // Transform the code - const result = transformer.transform(code, moduleType, inputSourceMap); - const diagnosticsState = getDiagnosticsState(this); - - diagnosticsState?.transformedModules.add(transformer.moduleName); - - callback(null, result.code, result.map); - } catch (error) { - console.warn(`[code-transformer-loader] Error transforming ${resourcePath}:`, error); - const diagnosticsState = getDiagnosticsState(this); - - diagnosticsState?.failedModules.add(moduleDetails.name); - callback(null, code, inputSourceMap); - } + return loader.call(this, code, inputSourceMap); } // Namespace to attach types to the function @@ -157,6 +38,13 @@ namespace codeTransformerLoader { instrumentations: AnyInstrumentationConfig[]; /** Optional path to a polyfill module for diagnostics_channel */ dcModule?: string; + /** + * Custom transforms registered on the matcher via orchestrion's + * `addTransform`. Functions, so webpack only: Turbopack and + * worker-based loaders serialize their options. Bind them with + * `createLoader` from the `/webpack-loader-factory` export instead. + */ + customTransforms?: Record; } } diff --git a/src/webpack.ts b/src/webpack.ts index dae7001..d6d4107 100644 --- a/src/webpack.ts +++ b/src/webpack.ts @@ -1,6 +1,7 @@ import type { Compiler } from 'webpack'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; +import { createHash } from 'crypto'; import { type CodeTransformerPluginOptions, } from './core.js'; @@ -13,6 +14,67 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const LOADER_PATH = resolve(__dirname, '..', 'cjs', 'webpack-loader.cjs'); const DIAGNOSTICS_STATE_KEY = '__codeTransformerWebpackDiagnostics'; +export interface CodeTransformerWebpackPluginOptions extends CodeTransformerPluginOptions { + /** + * The loader webpack should run, as a resolved path or a specifier + * resolvable from the compiler's context. Defaults to this package's own + * loader. + * + * Point this at a loader module built with `createLoader` from the + * `/webpack-loader-factory` export when loader options cannot carry + * `customTransforms` — under Turbopack, or with worker-based loaders such + * as `thread-loader`, which serialize them. + */ + loaderPath?: string; + /** + * An arbitrary string folded into the loader's cache key, for use with + * `cache: { type: 'filesystem' }`. + * + * The key already covers the instrumentations and the source text of every + * custom transform, so editing either invalidates cached modules. What it + * cannot see is data a transform reads without naming it — a captured + * variable, or a module-scope table of snippets. Bump this when such data + * changes, or derive it from the data itself. + */ + cacheVersion?: string; +} + +/** + * A stable identity for a set of loader options. + * + * Webpack keys a loader by its ruleset ident, not by the contents of its + * options, so with `cache: { type: 'filesystem' }` a changed config would + * otherwise reuse modules built by the previous one. Deriving the ident from + * the options makes the module identifier change with them. + * + * A transform's captured variables are invisible to `toString`, so a factory + * that returns textually identical functions for different inputs still hashes + * the same. `cacheVersion` is the escape hatch for that; binding the transforms + * with `createLoader` is the other, since webpack tracks the loader file's own + * contents. + */ +function loaderIdent( + options: { + instrumentations: unknown; + dcModule?: string; + customTransforms?: Record void>; + }, + cacheVersion?: string, +): string { + const hash = createHash('sha256'); + + hash.update(JSON.stringify(options.instrumentations)); + hash.update(options.dcModule ?? ''); + hash.update(cacheVersion ?? ''); + + for (const name of Object.keys(options.customTransforms ?? {}).sort()) { + hash.update(name); + hash.update(String(options.customTransforms?.[name])); + } + + return `code-transformer-${hash.digest('hex').slice(0, 16)}`; +} + type DiagnosticsState = { transformedModules: Set; failedModules: Set; @@ -38,9 +100,9 @@ function entryAssetNames(compilation: any): Set { } class CodeTransformerWebpackPlugin { - private readonly options: CodeTransformerPluginOptions; + private readonly options: CodeTransformerWebpackPluginOptions; - constructor(options: CodeTransformerPluginOptions) { + constructor(options: CodeTransformerWebpackPluginOptions) { this.options = options; } @@ -49,19 +111,30 @@ class CodeTransformerWebpackPlugin { compiler.options.module = compiler.options.module || ({ rules: [] } as any); compiler.options.module.rules = compiler.options.module.rules || []; - // Pass only what the loader reads, in JSON-serializable form — - // callbacks and RegExp instances would break bundlers that serialize - // loader options (e.g. Turbopack). + + // Pass only what the loader reads. Webpack hands loader options to the + // loader by reference, so `customTransforms` arrives intact; everything + // else stays JSON-serializable, keeping the options usable as-is by + // bundlers that serialize them (e.g. Turbopack) when no custom + // transforms are configured. + const loaderOptions = { + instrumentations: serializeInstrumentations(this.options.instrumentations), + ...(this.options.dcModule ? { dcModule: this.options.dcModule } : {}), + ...(this.options.customTransforms + ? { customTransforms: this.options.customTransforms } + : {}), + }; + compiler.options.module.rules.unshift({ test: /\.(c|m)?jsx?$|\.tsx?$/, enforce: 'pre', use: [ { - loader: LOADER_PATH, - options: { - instrumentations: serializeInstrumentations(this.options.instrumentations), - ...(this.options.dcModule ? { dcModule: this.options.dcModule } : {}), - }, + loader: this.options.loaderPath ?? LOADER_PATH, + options: loaderOptions, + // Without this webpack derives the ident from the rule's + // position, so a persistent cache survives a config change. + ident: loaderIdent(loaderOptions, this.options.cacheVersion), }, ], }); @@ -116,7 +189,7 @@ class CodeTransformerWebpackPlugin { } export default function codeTransformerWebpack( - options: CodeTransformerPluginOptions, + options: CodeTransformerWebpackPluginOptions, ): CodeTransformerWebpackPlugin { return new CodeTransformerWebpackPlugin(options); } diff --git a/test/exports.test.ts b/test/exports.test.ts index 72d9414..026b1b9 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -67,5 +67,36 @@ describe('package.json exports', () => { expect(stderr).toBe(''); expect(status).toBe(0); }); + + // Unlike the loader itself, the factory is a normal module with a named + // export — a wrapper loader requires it and calls `createLoader`. + const check = ` + if (typeof createLoader !== 'function') { + console.error('expected createLoader to be a function, got ' + typeof createLoader); + process.exit(2); + } + if (typeof createLoader({}) !== 'function') { + console.error('expected createLoader() to return a loader function'); + process.exit(2); + } + `; + + it(`import '${pkg.name}/webpack-loader-factory' (ESM) exposes createLoader`, () => { + const { status, stderr } = runNode( + 'module', + `import { createLoader } from '${pkg.name}/webpack-loader-factory';\n${check}`, + ); + expect(stderr).toBe(''); + expect(status).toBe(0); + }); + + it(`require '${pkg.name}/webpack-loader-factory' (CJS) exposes createLoader`, () => { + const { status, stderr } = runNode( + 'commonjs', + `const { createLoader } = require('${pkg.name}/webpack-loader-factory');\n${check}`, + ); + expect(stderr).toBe(''); + expect(status).toBe(0); + }); }); }); diff --git a/test/rollup.test.ts b/test/rollup.test.ts index b9ff115..e8d7bc8 100644 --- a/test/rollup.test.ts +++ b/test/rollup.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, inject } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, inject, vi } from 'vitest'; import codeTransformerPlugin from '../dist/esm/rollup.mjs'; import { rollup } from 'rollup'; import { join } from 'path'; @@ -13,6 +13,8 @@ import { diagnosticsSnippet, multiEntryInstrumentation, programInjectionTransform, + tracingChannelImportOverride, + twoChannelTestCase, INTEGRATION_MARKER, type MultiEntryFixture, type TestFixture, @@ -522,3 +524,86 @@ describe('Rollup customTransforms per-file injection', () => { expect(code).toContain(INTEGRATION_MARKER); }); }); + +describe('Rollup tracingChannelImport override', () => { + let fixture: TestFixture; + + beforeEach(() => { + fixture = createTestFixture(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + async function bundleWithOverride( + input: string, + instrumentations: unknown[], + ): Promise { + // Plain rollup has no bare-specifier resolution, so the snippet + // imports the library entry by absolute path. + const libraryEntry = createTracingLibraryFixture(fixture); + const snippet = `import { subscribeTo } from ${JSON.stringify(libraryEntry)};\nsubscribeTo('test-module');`; + + const bundle = await rollup({ + input, + plugins: [ + codeTransformerPlugin({ + instrumentations: instrumentations as never, + customTransforms: { + tracingChannelImport: tracingChannelImportOverride({ + 'test-module': snippet, + }), + }, + }), + ], + external: (id) => builtinModules.includes(id), + }); + + const { output } = await bundle.generate({ format: 'es' }); + return output[0].code; + } + + it('should inject once for a file whose channel is set up twice', async () => { + const testCase = twoChannelTestCase; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const code = await bundleWithOverride(testFile, testCase.instrumentations); + + // Both instrumentations applied, and the built-in transform still ran + expect(code).toContain('test:alpha'); + expect(code).toContain('test:beta'); + expect(code).toContain('tr_ch_apm_tracingChannel'); + + // One injection despite the override being called once per channel + expect(code.match(/subscribeTo\(["']test-module["']\)/g)).toHaveLength(1); + expect(code).toContain(INTEGRATION_MARKER); + }); + + // The reason to override this transform rather than add a `Program` config, + // which would match every file the module matcher does. + it('should not inject into a file where nothing was instrumented', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const code = await bundleWithOverride(testFile, [ + { + ...testCase.instrumentation, + functionQuery: { functionName: 'doesNotExist', kind: 'Async' }, + }, + ]); + + expect(code).not.toContain('subscribeTo'); + expect(code).not.toContain(INTEGRATION_MARKER); + // The file still fails as it normally would + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Code transformation failed'), + expect.anything(), + ); + + warn.mockRestore(); + }); +}); diff --git a/test/test-utils.ts b/test/test-utils.ts index f315ac0..7864b3b 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -100,6 +100,53 @@ export function programInjectionTransform(snippets: Record) { }; } +/** + * A custom transform that overrides orchestrion's built-in + * `tracingChannelImport` — the pattern the README documents. Orchestrion calls + * it while setting up a file's diagnostics channel, so it only runs for files + * where a function was really wrapped, and once per channel rather than once + * per file. + */ +export function tracingChannelImportOverride(snippets: Record) { + return (state: any, program: any): void => { + // The built-in adds the diagnostics_channel import that the channel + // declaration orchestrion appends next depends on. + state.transforms.defaults.tracingChannelImport(state, program); + + const snippet = snippets[state.module.name]; + + if (!snippet || program.__integrationInjected) return; + program.__integrationInjected = true; + + const statements = parse(snippet, { module: state.moduleType === 'esm' }).body; + const index = program.body.findIndex( + (node: any) => + node.declarations?.[0]?.id?.properties?.[0]?.value?.name === + 'tr_ch_apm_tracingChannel', + ); + + program.body.splice(index + 1, 0, ...statements); + }; +} + +/** Two instrumented functions in one file, so its channel is set up twice. */ +export const twoChannelTestCase = { + filename: 'two-channels.js', + code: ` +export async function alpha() { return 1; } +export async function beta() { return 2; } +`, + instrumentations: ['alpha', 'beta'].map((fn) => ({ + channelName: `test:${fn}`, + module: { + name: 'test-module', + versionRange: '>=1.0.0' as any, + filePath: 'two-channels.js', + }, + functionQuery: { functionName: fn, kind: 'Async' as const }, + })), +}; + /** * A two-entry app that also produces non-entry chunks: `shared.js` is imported * by both entries and `lazy.js` is only reachable through a dynamic import. diff --git a/test/type-resolution.test.ts b/test/type-resolution.test.ts index ba95758..3f1bd22 100644 --- a/test/type-resolution.test.ts +++ b/test/type-resolution.test.ts @@ -26,7 +26,16 @@ const root = join(__dirname, '..'); const tsc = createRequire(import.meta.url).resolve('typescript/bin/tsc'); const PKG = '@apm-js-collab/code-transformer-bundler-plugins'; -const ALL_SUBPATHS = ['core', 'rollup', 'webpack', 'vite', 'esbuild', 'bun', 'webpack-loader'] as const; +const ALL_SUBPATHS = [ + 'core', + 'rollup', + 'webpack', + 'vite', + 'esbuild', + 'bun', + 'webpack-loader', + 'webpack-loader-factory', +] as const; // `vite` ships `exports`-only type declarations that legacy `node10` resolution // cannot follow, so the `/vite` subpath's peer types are unresolvable there — @@ -116,6 +125,10 @@ afterAll(() => { }); describe('published type declarations resolve for consumers', () => { + // Each mode spawns a full `tsc` over every subpath with `skipLibCheck` + // off, which takes several seconds — well past vitest's default timeout. + const TSC_TIMEOUT_MS = 60_000; + it.each(modes)('$name: every subpath type-checks cleanly', (mode) => { const source = consumerFor(mode.subpaths); for (const file of mode.files) writeFileSync(join(fixture, file), source); @@ -137,5 +150,5 @@ describe('published type declarations resolve for consumers', () => { if (/error TS510[78]|moduleResolution.*removed/.test(output)) return; expect(status, output).toBe(0); - }); + }, TSC_TIMEOUT_MS); }); diff --git a/test/webpack-custom-transforms.test.ts b/test/webpack-custom-transforms.test.ts new file mode 100644 index 0000000..48553d1 --- /dev/null +++ b/test/webpack-custom-transforms.test.ts @@ -0,0 +1,569 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import codeTransformerPlugin from '../dist/esm/webpack.mjs'; +import { createLoader } from '../dist/esm/webpack-loader-factory.mjs'; +import webpack from 'webpack'; +import { join, dirname } from 'path'; +import { writeFileSync, readFileSync } from 'fs'; +import { builtinModules, createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import { + createTestFixture, + createTracingLibraryFixture, + commonTestCases, + diagnosticsSnippet, + programInjectionTransform, + INTEGRATION_MARKER, + TRACING_LIBRARY_NAME, + type TestFixture, +} from './test-utils.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const FACTORY_PATH = join(__dirname, '..', 'dist', 'cjs', 'webpack-loader-factory.cjs'); +const MERIYAH_PATH = require.resolve('meriyah'); + +/** The injection site: same module matcher, Program node, custom transform. */ +function injectionConfig(module: Record) { + return { + channelName: 'integration-injection', + module, + astQuery: 'Program', + transform: 'injectIntegration', + }; +} + +function integrationSnippet(moduleName: string): string { + return `import { subscribeTo } from '${TRACING_LIBRARY_NAME}';\nsubscribeTo('${moduleName}');`; +} + +/** + * Writes the kind of loader a downstream library would ship: a CommonJS module + * that binds its custom transforms with `createLoader` at require time, so the + * functions never have to survive loader-option serialization. + */ +function writeWrapperLoader(fixture: TestFixture, moduleName: string): string { + const loaderPath = join(fixture.testDir, 'wrapper-loader.cjs'); + + writeFileSync( + loaderPath, + `const { createLoader } = require(${JSON.stringify(FACTORY_PATH)}); +const { parse } = require(${JSON.stringify(MERIYAH_PATH)}); + +const SNIPPET = ${JSON.stringify(integrationSnippet(moduleName))}; + +module.exports = createLoader({ + customTransforms: { + injectIntegration(state, program) { + if (state.module.name !== ${JSON.stringify(moduleName)}) return; + // A file can be matched by several configs; only inject once. + if (program.__integrationInjected) return; + program.__integrationInjected = true; + + const statements = parse(SNIPPET, { module: state.moduleType === 'esm' }).body; + const index = program.body.findIndex((node) => node.directive === 'use strict'); + program.body.splice(index + 1, 0, ...statements); + }, + }, +}); +`, + ); + + return loaderPath; +} + +function runWebpack(config: webpack.Configuration, outputPath: string): Promise { + return new Promise((resolve, reject) => { + const compiler = webpack(config); + + compiler.run((err, stats) => { + if (err) { + reject(err); + return; + } + + if (stats?.hasErrors()) { + reject(new Error(stats.toString())); + return; + } + + let source: string; + try { + source = readFileSync(outputPath, 'utf8'); + } catch (readErr) { + reject(new Error(`Failed to read output file: ${readErr}`)); + return; + } + + compiler.close(() => resolve(source)); + }); + }); +} + +describe('Webpack custom transforms via a wrapper loader', () => { + let fixture: TestFixture; + + beforeEach(() => { + fixture = createTestFixture(); + }); + + afterEach(() => { + fixture.cleanup(); + vi.restoreAllMocks(); + }); + + it('should apply a transform bound by createLoader, with instrumentations still passed as JSON', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + createTracingLibraryFixture(fixture); + + const outputPath = join(fixture.testDir, 'dist', 'bundle.js'); + const source = await runWebpack( + { + mode: 'production', + entry: testFile, + output: { path: join(fixture.testDir, 'dist'), filename: 'bundle.js' }, + module: { + rules: [ + { + test: /\.js$/, + use: { + loader: writeWrapperLoader(fixture, 'test-module'), + options: { + instrumentations: [ + testCase.instrumentation, + injectionConfig(testCase.instrumentation.module), + ], + }, + }, + }, + ], + }, + externals: Array.from(builtinModules), + optimization: { minimize: false }, + }, + outputPath, + ); + + // The instrumentation itself still applies + expect(source).toContain('test:esmodule'); + // The snippet was injected and its bare import resolved into the bundle + expect(source).toMatch(/subscribeTo\(["']test-module["']\)/); + expect(source).toContain(INTEGRATION_MARKER); + }); + + it('should use the wrapper loader when the plugin is given a loaderPath, keeping diagnostics', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + createTracingLibraryFixture(fixture); + + const outputPath = join(fixture.testDir, 'dist', 'bundle.js'); + const source = await runWebpack( + { + mode: 'production', + entry: testFile, + output: { path: join(fixture.testDir, 'dist'), filename: 'bundle.js' }, + plugins: [ + codeTransformerPlugin({ + loaderPath: writeWrapperLoader(fixture, 'test-module'), + instrumentations: [ + testCase.instrumentation, + injectionConfig(testCase.instrumentation.module), + ], + injectDiagnostics: diagnosticsSnippet, + }), + ], + externals: Array.from(builtinModules), + optimization: { minimize: false }, + }, + outputPath, + ); + + expect(source).toContain('test:esmodule'); + expect(source).toContain(INTEGRATION_MARKER); + // The loader still reports into the plugin's diagnostics state + expect(source).toContain('transformedModules=test-module'); + }); + +}); + +describe('Webpack plugin customTransforms', () => { + let fixture: TestFixture; + + beforeEach(() => { + fixture = createTestFixture(); + }); + + afterEach(() => { + fixture.cleanup(); + vi.restoreAllMocks(); + }); + + it('should apply transforms passed straight to the plugin', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + createTracingLibraryFixture(fixture); + + const outputPath = join(fixture.testDir, 'dist', 'bundle.js'); + const source = await runWebpack( + { + mode: 'production', + entry: testFile, + output: { path: join(fixture.testDir, 'dist'), filename: 'bundle.js' }, + plugins: [ + codeTransformerPlugin({ + instrumentations: [ + testCase.instrumentation, + injectionConfig(testCase.instrumentation.module), + ], + customTransforms: { + injectIntegration: programInjectionTransform({ + 'test-module': integrationSnippet('test-module'), + }), + }, + injectDiagnostics: diagnosticsSnippet, + }), + ], + externals: Array.from(builtinModules), + optimization: { minimize: false }, + }, + outputPath, + ); + + // The instrumentation itself still applies + expect(source).toContain('test:esmodule'); + // The snippet was injected and its bare import resolved into the bundle + expect(source).toMatch(/subscribeTo\(["']test-module["']\)/); + expect(source).toContain(INTEGRATION_MARKER); + expect(source).toContain('transformedModules=test-module'); + }); + + it('should forward the transforms to the loader by reference', () => { + const injectIntegration = () => {}; + const compiler = webpack({ + mode: 'production', + entry: join(fixture.moduleDir, commonTestCases.esmodule.filename), + plugins: [ + codeTransformerPlugin({ + instrumentations: [], + customTransforms: { injectIntegration }, + }), + ], + }); + + const rule = compiler.options.module.rules[0] as { + use: Array<{ options: { customTransforms?: Record } }>; + }; + + // By reference, not a copy: a serialized round trip would drop it. + expect(rule.use[0]!.options.customTransforms?.injectIntegration).toBe(injectIntegration); + + compiler.close(() => {}); + }); + + // A persistent cache keyed only on the rule's position would serve the + // first build's output forever. + describe('with a filesystem cache', () => { + /** + * Injects `marker` at the top of the program. Written as a factory over + * the *source* of the transform rather than over a captured variable, + * because captured data is invisible to the cache key. + */ + function markerTransform(marker: string) { + // eslint-disable-next-line no-new-func + return new Function( + 'state', + 'program', + `if (program.__injected) return; + program.__injected = true; + program.body.unshift({ + type: 'ExpressionStatement', + expression: { type: 'Identifier', name: '${marker}' }, + });`, + ) as (state: unknown, program: unknown) => void; + } + + function build( + transform: (state: any, program: any) => void, + extra: { cacheVersion?: string } = {}, + ): Promise { + const testCase = commonTestCases.esmodule; + + return runWebpack( + { + mode: 'production', + entry: join(fixture.moduleDir, testCase.filename), + output: { path: join(fixture.testDir, 'dist'), filename: 'bundle.js' }, + cache: { + type: 'filesystem', + cacheDirectory: join(fixture.testDir, '.webpack-cache'), + }, + plugins: [ + codeTransformerPlugin({ + ...extra, + instrumentations: [ + testCase.instrumentation, + injectionConfig(testCase.instrumentation.module), + ], + customTransforms: { injectIntegration: transform }, + }), + ], + externals: Array.from(builtinModules), + optimization: { minimize: false }, + }, + join(fixture.testDir, 'dist', 'bundle.js'), + ); + } + + const buildWith = (marker: string, extra: { cacheVersion?: string } = {}) => + build(markerTransform(marker), extra); + + beforeEach(() => { + writeFileSync( + join(fixture.moduleDir, commonTestCases.esmodule.filename), + commonTestCases.esmodule.code, + ); + }); + + it('should rebuild when a transform body changes', async () => { + expect(await buildWith('MARKER_ONE')).toContain('MARKER_ONE'); + + const second = await buildWith('MARKER_TWO'); + expect(second).toContain('MARKER_TWO'); + expect(second).not.toContain('MARKER_ONE'); + }); + + it('should still serve the cache when nothing changed', async () => { + let calls = 0; + // Source text identical across both builds, so the cache key is too. + const transform = (state: any, program: any) => { + calls++; + markerTransform('MARKER_ONE')(state, program); + }; + + expect(await build(transform)).toContain('MARKER_ONE'); + expect(calls).toBe(1); + + expect(await build(transform)).toContain('MARKER_ONE'); + expect(calls, 'the second build should have come from the cache').toBe(1); + }); + + // The cache key cannot see data a transform reads without naming it, so + // `cacheVersion` is the way to invalidate on a change it cannot detect. + it('should rebuild when cacheVersion changes', async () => { + const first = await buildWith('MARKER_ONE', { cacheVersion: 'v1' }); + expect(first).toContain('MARKER_ONE'); + + const second = await buildWith('MARKER_TWO', { cacheVersion: 'v2' }); + expect(second).toContain('MARKER_TWO'); + expect(second).not.toContain('MARKER_ONE'); + }); + }); + + // Webpack keys a loader by its ruleset ident, so with `cache: { type: + // 'filesystem' }` an ident derived from the rule's position would let a + // changed config reuse modules built by the previous one. + describe('loader ident', () => { + function identFor(options: Parameters[0]): string { + const compiler = webpack({ + mode: 'production', + entry: join(fixture.moduleDir, commonTestCases.esmodule.filename), + plugins: [codeTransformerPlugin(options)], + }); + + const rule = compiler.options.module.rules[0] as { + use: Array<{ ident: string }>; + }; + const ident = rule.use[0]!.ident; + + compiler.close(() => {}); + return ident; + } + + const instrumentations = [commonTestCases.esmodule.instrumentation]; + + it('should stay stable for an unchanged config', () => { + expect(identFor({ instrumentations })).toBe(identFor({ instrumentations })); + }); + + it('should change when the instrumentations change', () => { + expect(identFor({ instrumentations })).not.toBe( + identFor({ + instrumentations: [ + { ...commonTestCases.esmodule.instrumentation, channelName: 'other' }, + ], + }), + ); + }); + + it('should change when a transform body changes', () => { + expect( + identFor({ + instrumentations, + customTransforms: { injectIntegration: () => 'v1' }, + }), + ).not.toBe( + identFor({ + instrumentations, + customTransforms: { injectIntegration: () => 'v2' }, + }), + ); + }); + + it('should change when dcModule changes', () => { + expect(identFor({ instrumentations, dcModule: 'a' })).not.toBe( + identFor({ instrumentations, dcModule: 'b' }), + ); + }); + + it('should change when cacheVersion changes', () => { + expect(identFor({ instrumentations, cacheVersion: 'v1' })).not.toBe( + identFor({ instrumentations, cacheVersion: 'v2' }), + ); + }); + }); +}); + +describe('createLoader matcher caching', () => { + let fixture: TestFixture; + + beforeEach(() => { + fixture = createTestFixture(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + /** Minimal stand-in for webpack's loader context. */ + function runLoader( + loader: ReturnType, + resourcePath: string, + code: string, + options: unknown, + ): Promise { + return new Promise((resolve, reject) => { + loader.call( + { + resourcePath, + getOptions: () => options, + async: + () => + (err: Error | null, result?: string) => + err ? reject(err) : resolve(result!), + }, + code, + ); + }); + } + + // The matcher cache is keyed on the instrumentations alone, so a cache + // shared across loaders would hand the second loader the first one's + // transforms. + it('should not share matchers between loaders with different customTransforms', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const instrumentations = [ + testCase.instrumentation, + injectionConfig(testCase.instrumentation.module), + ]; + + const makeLoader = (marker: string) => + createLoader({ + customTransforms: { + injectIntegration(_state: any, program: any) { + if (program.__integrationInjected) return; + program.__integrationInjected = true; + program.body.unshift({ + type: 'ExpressionStatement', + expression: { type: 'Identifier', name: marker }, + }); + }, + }, + }); + + const first = await runLoader(makeLoader('FIRST_MARKER'), testFile, testCase.code, { + instrumentations, + }); + const second = await runLoader(makeLoader('SECOND_MARKER'), testFile, testCase.code, { + instrumentations, + }); + + expect(first).toContain('FIRST_MARKER'); + expect(first).not.toContain('SECOND_MARKER'); + expect(second).toContain('SECOND_MARKER'); + expect(second).not.toContain('FIRST_MARKER'); + }); + + it('should fall back to the instrumentations createLoader was built with', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const loader = createLoader({ instrumentations: [testCase.instrumentation] }); + const output = await runLoader(loader, testFile, testCase.code, {}); + + expect(output).toContain('test:esmodule'); + }); + + it('should let per-rule options override the baked-in instrumentations', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const loader = createLoader({ instrumentations: [testCase.instrumentation] }); + const output = await runLoader(loader, testFile, testCase.code, { + instrumentations: [ + { ...testCase.instrumentation, channelName: 'override:channel' }, + ], + }); + + expect(output).toContain('override:channel'); + expect(output).not.toContain('test:esmodule'); + }); + + it('should let per-rule transforms override the baked-in ones by name', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const marker = (name: string) => (_state: any, program: any) => { + if (program.__integrationInjected) return; + program.__integrationInjected = true; + program.body.unshift({ + type: 'ExpressionStatement', + expression: { type: 'Identifier', name }, + }); + }; + + const loader = createLoader({ + instrumentations: [ + testCase.instrumentation, + injectionConfig(testCase.instrumentation.module), + ], + customTransforms: { injectIntegration: marker('BAKED_IN') }, + }); + + const output = await runLoader(loader, testFile, testCase.code, { + customTransforms: { injectIntegration: marker('PER_RULE') }, + }); + + expect(output).toContain('PER_RULE'); + expect(output).not.toContain('BAKED_IN'); + }); + + it('should pass code through when no instrumentations are configured', async () => { + const testCase = commonTestCases.esmodule; + const testFile = join(fixture.moduleDir, testCase.filename); + writeFileSync(testFile, testCase.code); + + const output = await runLoader(createLoader(), testFile, testCase.code, {}); + + expect(output).toBe(testCase.code); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index b89e1c4..c7ecd43 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,7 @@ const entries = { esbuild: 'src/esbuild.ts', bun: 'src/bun.ts', 'webpack-loader': 'src/webpack-loader.ts', + 'webpack-loader-factory': 'src/webpack-loader-factory.ts', }; /** diff --git a/yarn.lock b/yarn.lock index 09cf5b3..77ebf4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@apm-js-collab/code-transformer@^0.18.0": - version "0.18.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.18.0.tgz#722972c05f04bc37f4bbd5067f42ffd7a990eee9" - integrity sha512-aN3Oq8r1J3gPJtCwErP664gM0+HhM1I1lujPr9TMTCcEl/joQQbpGpeMdts9B1+W2wHMsvioDMv5F4PvMWE6gw== +"@apm-js-collab/code-transformer@^0.18.1": + version "0.18.1" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz#66ce01cfe9607779b4abebb4f54c49a46e8b48ca" + integrity sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ== dependencies: "@types/estree" "^1.0.8" astring "^1.9.0"