Skip to content

Commit 2e4a083

Browse files
committed
Add webpack loader support
This is needed for Next.js 16 as turbopack only supports webpack loaders and not plugins.
1 parent 1a2aaa2 commit 2e4a083

3 files changed

Lines changed: 500 additions & 0 deletions

File tree

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"webpack",
99
"vite",
1010
"esbuild",
11+
"turbopack",
12+
"loader",
1113
"plugin",
1214
"instrumentation",
1315
"apm",
@@ -59,6 +61,10 @@
5961
"types": "./dist/cjs/esbuild.d.ts",
6062
"default": "./dist/cjs/esbuild.js"
6163
}
64+
},
65+
"./webpack-loader": {
66+
"types": "./dist/cjs/webpack-loader.d.cts",
67+
"default": "./dist/cjs/webpack-loader.cjs"
6268
}
6369
},
6470
"files": [

src/webpack-loader.cts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { create, type InstrumentationConfig } from '@apm-js-collab/code-transformer';
2+
import { join, extname } from 'path';
3+
import { readFileSync } from 'fs';
4+
import * as moduleDetailsFromPathImport from 'module-details-from-path';
5+
6+
// Handle CJS default export - module-details-from-path exports a function directly
7+
const moduleDetailsFromPath = (moduleDetailsFromPathImport as any).default || moduleDetailsFromPathImport as any;
8+
9+
/**
10+
* Helper function to get module version from package.json
11+
*/
12+
function getModuleVersion(basedir: string): string | undefined {
13+
try {
14+
const packageJsonPath = join(basedir, 'package.json');
15+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
16+
if (packageJson.version) {
17+
return packageJson.version;
18+
}
19+
} catch (error) {
20+
//
21+
}
22+
23+
return undefined; // No version found
24+
}
25+
26+
// Matcher cache with config hash for cache invalidation
27+
const matcherCache = new Map<string, ReturnType<typeof create>>();
28+
29+
/**
30+
* Get or create a matcher instance with caching based on config hash
31+
*/
32+
function getMatcher(instrumentations: InstrumentationConfig[], dcModule?: string) {
33+
const configHash = JSON.stringify({ instrumentations, dcModule });
34+
35+
if (matcherCache.has(configHash)) {
36+
return matcherCache.get(configHash)!;
37+
}
38+
39+
// Free old matchers to prevent memory leaks
40+
for (const [hash, matcher] of matcherCache.entries()) {
41+
if (hash !== configHash) {
42+
matcher.free();
43+
matcherCache.delete(hash);
44+
}
45+
}
46+
47+
const matcher = create(instrumentations, dcModule ?? null);
48+
matcherCache.set(configHash, matcher);
49+
return matcher;
50+
}
51+
52+
/**
53+
* Webpack loader that instruments JavaScript code using code-transformer
54+
*
55+
* This is a webpack loader (not a plugin) for compatibility with tools that only support loaders,
56+
* such as Next.js Turbopack. Unlike the other exports in this package, this does not use unplugin.
57+
*/
58+
function codeTransformerLoader(
59+
this: any,
60+
code: string,
61+
inputSourceMap?: any
62+
) {
63+
const callback = this.async();
64+
const options: codeTransformerLoader.Options = this.getOptions();
65+
const resourcePath: string = this.resourcePath;
66+
67+
// Determine if this is an ES module using multiple methods for accurate detection
68+
const ext = extname(resourcePath);
69+
let isModule = ext === '.mjs' || ext === '.ts' || ext === '.tsx';
70+
71+
// For .js files, use content analysis for module detection
72+
if (ext === '.js') {
73+
isModule = code.includes('export ') || code.includes('import ');
74+
}
75+
76+
// Try to get module details from the file path
77+
const moduleDetails = moduleDetailsFromPath(resourcePath);
78+
79+
// If no module details found, the file is not part of a module
80+
if (!moduleDetails) {
81+
return callback(null, code, inputSourceMap);
82+
}
83+
84+
// Use module details for accurate module information
85+
const moduleName = moduleDetails.name;
86+
const moduleVersion = getModuleVersion(moduleDetails.basedir);
87+
88+
// If no version found
89+
if (!moduleVersion) {
90+
return callback(null, code, inputSourceMap);
91+
}
92+
93+
// Try to get a transformer for this file
94+
const matcher = getMatcher(options.instrumentations, options.dcModule);
95+
const transformer = matcher.getTransformer(
96+
moduleName,
97+
moduleVersion,
98+
moduleDetails.path
99+
);
100+
101+
if (!transformer) {
102+
// No instrumentations match this file
103+
return callback(null, code, inputSourceMap);
104+
}
105+
106+
try {
107+
// Transform the code
108+
const result = transformer.transform(code, isModule);
109+
110+
callback(null, result, undefined);
111+
} catch (error) {
112+
console.warn(`[code-transformer-loader] Error transforming ${resourcePath}:`, error);
113+
callback(null, code, inputSourceMap);
114+
} finally {
115+
transformer.free();
116+
}
117+
}
118+
119+
// Cleanup on process exit
120+
process.on('exit', () => {
121+
for (const matcher of matcherCache.values()) {
122+
matcher.free();
123+
}
124+
matcherCache.clear();
125+
});
126+
127+
// Namespace to attach types to the function
128+
namespace codeTransformerLoader {
129+
/** Options for the code transformer webpack loader */
130+
export interface Options {
131+
/** Array of instrumentation configurations */
132+
instrumentations: InstrumentationConfig[];
133+
/** Optional path to a polyfill module for diagnostics_channel */
134+
dcModule?: string;
135+
}
136+
}
137+
138+
// Use export = for proper CommonJS module.exports
139+
export = codeTransformerLoader;

0 commit comments

Comments
 (0)