Skip to content

Commit edf2e8f

Browse files
committed
feat(instrumentor): ESM loader hook via module.register
Add an ESM loader that intercepts import() and static imports on Node >= 20.6. It runs in a dedicated loader thread, applies Babel coverage + compare-hooks transforms, and hands the rewritten source back to the main thread for evaluation. Each module gets a preamble that allocates its own counter buffer through the Fuzzer global (which lives on the main thread where the transformed code actually executes). On older Node versions the loader is silently skipped and the CJS hookRequire path continues to work as before.
1 parent 113c1c0 commit edf2e8f

2 files changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright 2026 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Node.js module-loader hook for ESM instrumentation.
19+
*
20+
* Registered via module.register() from registerInstrumentor().
21+
* Runs in a dedicated loader thread — it has no access to the
22+
* native fuzzer addon or to globalThis.Fuzzer. All it does is
23+
* transform source code and hand it back. The transformed code
24+
* executes in the main thread, where the Fuzzer global exists.
25+
*/
26+
27+
import { createRequire } from "node:module";
28+
import { fileURLToPath } from "node:url";
29+
30+
// Load CJS-compiled Babel plugins via createRequire so we don't
31+
// depend on Node.js CJS-named-export detection (varies by version).
32+
const require = createRequire(import.meta.url);
33+
const { transformSync } =
34+
require("@babel/core") as typeof import("@babel/core");
35+
const { esmCodeCoverage } =
36+
require("./plugins/esmCodeCoverage.js") as typeof import("./plugins/esmCodeCoverage.js");
37+
const { compareHooks } =
38+
require("./plugins/compareHooks.js") as typeof import("./plugins/compareHooks.js");
39+
40+
// Already-instrumented code contains this marker.
41+
const INSTRUMENTATION_MARKER = "Fuzzer.coverageTracker.incrementCounter";
42+
43+
// Counter buffer variable injected into each instrumented module.
44+
const COUNTER_ARRAY = "__jazzer_cov";
45+
46+
interface LoaderConfig {
47+
includes: string[];
48+
excludes: string[];
49+
}
50+
51+
let config: LoaderConfig;
52+
53+
export function initialize(data: LoaderConfig): void {
54+
config = data;
55+
}
56+
57+
interface LoadResult {
58+
format?: string;
59+
source?: string | ArrayBuffer | SharedArrayBuffer | Uint8Array;
60+
shortCircuit?: boolean;
61+
}
62+
63+
type LoadFn = (
64+
url: string,
65+
context: { format?: string | null },
66+
nextLoad: (
67+
url: string,
68+
context: { format?: string | null },
69+
) => Promise<LoadResult>,
70+
) => Promise<LoadResult>;
71+
72+
export const load: LoadFn = async function load(url, context, nextLoad) {
73+
const result = await nextLoad(url, context);
74+
75+
if (result.format !== "module" || !result.source) {
76+
return result;
77+
}
78+
79+
// Only instrument file:// URLs (skip builtins, data:, https:, etc.)
80+
if (!url.startsWith("file://")) {
81+
return result;
82+
}
83+
84+
const filename = fileURLToPath(url);
85+
if (!shouldInstrument(filename)) {
86+
return result;
87+
}
88+
89+
const code = result.source.toString();
90+
91+
// Avoid double-instrumenting code already processed by the CJS path
92+
// or by the Jest transformer.
93+
if (code.includes(INSTRUMENTATION_MARKER)) {
94+
return result;
95+
}
96+
97+
const instrumented = instrumentModule(code, filename);
98+
if (!instrumented) {
99+
return result;
100+
}
101+
102+
return { ...result, source: instrumented };
103+
};
104+
105+
// ── Instrumentation ──────────────────────────────────────────────
106+
107+
function instrumentModule(code: string, filename: string): string | null {
108+
const coverage = esmCodeCoverage();
109+
110+
let transformed: ReturnType<typeof transformSync>;
111+
try {
112+
transformed = transformSync(code, {
113+
filename,
114+
sourceFileName: filename,
115+
sourceMaps: "inline",
116+
plugins: [coverage.plugin, compareHooks],
117+
sourceType: "module",
118+
});
119+
} catch {
120+
// Babel parse failures on non-JS assets should not crash the
121+
// loader — fall through and return the original source.
122+
return null;
123+
}
124+
125+
const edges = coverage.edgeCount();
126+
if (edges === 0 || !transformed?.code) {
127+
return null;
128+
}
129+
130+
// Prepend a one-liner that allocates this module's counter buffer
131+
// and registers it with libFuzzer via the main-thread Fuzzer global.
132+
const preamble =
133+
`const ${COUNTER_ARRAY} = ` +
134+
`Fuzzer.coverageTracker.createModuleCounters(${edges});\n`;
135+
136+
return preamble + transformed.code;
137+
}
138+
139+
// ── Include / exclude filtering ──────────────────────────────────
140+
141+
function shouldInstrument(filepath: string): boolean {
142+
const { includes, excludes } = config;
143+
const included = includes.some((p) => filepath.includes(p));
144+
const excluded = excludes.some((p) => filepath.includes(p));
145+
return included && !excluded;
146+
}

packages/instrumentor/instrument.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17+
import * as path from "path";
18+
import { pathToFileURL } from "url";
19+
1720
import {
1821
BabelFileResult,
1922
PluginItem,
@@ -183,6 +186,18 @@ export class Instrumentor {
183186
);
184187
}
185188

189+
get dryRun(): boolean {
190+
return this.isDryRun;
191+
}
192+
193+
get includePatterns(): string[] {
194+
return this.includes;
195+
}
196+
197+
get excludePatterns(): string[] {
198+
return this.excludes;
199+
}
200+
186201
private shouldCollectCodeCoverage(filepath: string): boolean {
187202
return (
188203
this.shouldCollectSourceCodeCoverage &&
@@ -223,4 +238,46 @@ export function registerInstrumentor(instrumentor: Instrumentor) {
223238
// instrumentor but the filename will still have a .ts extension
224239
{ extensions: [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] },
225240
);
241+
242+
registerEsmHooks(instrumentor);
243+
}
244+
245+
/**
246+
* On Node.js >= 20.6 register an ESM loader hook so that
247+
* import() and static imports are instrumented too.
248+
*/
249+
function registerEsmHooks(instrumentor: Instrumentor): void {
250+
if (instrumentor.dryRun) {
251+
return;
252+
}
253+
254+
const [major, minor] = process.versions.node.split(".").map(Number);
255+
if (major < 20 || (major === 20 && minor < 6)) {
256+
return;
257+
}
258+
259+
try {
260+
// Dynamic require — the node:module API may not expose
261+
// `register` on older versions even if the check above
262+
// passed (e.g. unusual builds).
263+
const { register } = require("node:module") as {
264+
register: (
265+
specifier: string,
266+
options: { parentURL: string; data: unknown },
267+
) => void;
268+
};
269+
270+
const loaderUrl = pathToFileURL(
271+
path.join(__dirname, "esm-loader.mjs"),
272+
).href;
273+
register(loaderUrl, {
274+
parentURL: pathToFileURL(__filename).href,
275+
data: {
276+
includes: instrumentor.includePatterns,
277+
excludes: instrumentor.excludePatterns,
278+
},
279+
});
280+
} catch {
281+
// Silently fall back to CJS-only instrumentation.
282+
}
226283
}

0 commit comments

Comments
 (0)