Skip to content

Commit 094af5b

Browse files
authored
feat(core): bundle cjs deps (#396)
- Close VP-139 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds CJS dependency bundling for `tsdown` to avoid runtime externals. > > - New `build-support/find-create-require.ts` scans ESM for `createRequire` patterns, replaces third‑party `require()` specifiers with local entry files, and returns collected modules > - New `build-support/build-cjs-deps.ts` generates shim entry files and bundles them as CJS chunks via rolldown > - Updates `build.ts` to run the transform plugin during tsdown build, collect modules, and call `buildCjsDeps`; also refactors import‑rewrite utility usage > - Adjusts `packages/core/package.json` deps (adds `@oxc-project/types`, moves parser/Babel-related tooling to devDeps) and syncs lockfile > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b10663a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5a139b8 commit 094af5b

6 files changed

Lines changed: 397 additions & 34 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { writeFile, rm } from 'node:fs/promises';
2+
import { join } from 'node:path';
3+
4+
import { build } from 'rolldown';
5+
6+
export function createModuleEntryFileName(module: string) {
7+
// remove the .js extension in the require path
8+
// like `require('semver/functions/coerce.js') -> npm_entry_semver_functions_coerce.cjs`
9+
return `npm_entry_${module.replaceAll('/', '_').replace('.js', '')}.cjs`;
10+
}
11+
12+
export async function buildCjsDeps(modules: Set<string>, distDir: string) {
13+
const distFiles = new Set<string>();
14+
for (const module of modules) {
15+
const filename = createModuleEntryFileName(module);
16+
const distFile = join(distDir, `_${filename}`);
17+
await writeFile(distFile, `module.exports = require('${module}')\n`);
18+
distFiles.add(distFile);
19+
}
20+
if (distFiles.size === 0) {
21+
return;
22+
}
23+
await build({
24+
input: Array.from(distFiles),
25+
platform: 'node',
26+
treeshake: true,
27+
output: {
28+
format: 'cjs',
29+
dir: distDir,
30+
entryFileNames: (chunkInfo) => {
31+
return `${chunkInfo.name.slice(1)}.cjs`;
32+
},
33+
chunkFileNames: (chunkInfo) => {
34+
return `npm_cjs_chunk_${chunkInfo.name || 'index'}.cjs`;
35+
},
36+
},
37+
});
38+
39+
for (const file of distFiles) {
40+
await rm(file);
41+
}
42+
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { builtinModules } from 'node:module';
2+
3+
import {
4+
parse,
5+
type ParseResult,
6+
Visitor,
7+
type CallExpression,
8+
type Expression,
9+
type StaticMemberExpression,
10+
type VariableDeclarator,
11+
} from 'oxc-parser';
12+
13+
import { createModuleEntryFileName } from './build-cjs-deps';
14+
15+
// Node.js built-in modules (without node: prefix)
16+
const nodeBuiltins = new Set(builtinModules);
17+
18+
// TODO, analysis the optional peerDependencies in the dependencies tree to exclude them in the future
19+
const optionalCjsExternal = new Set<string>(['oxc-resolver', 'synckit']);
20+
21+
/**
22+
* Check if a module specifier is a third-party package
23+
* (not a Node.js built-in, not a relative path)
24+
*/
25+
function isThirdPartyModule(specifier: string): boolean {
26+
// Filter out relative paths
27+
if (specifier.startsWith('./') || specifier.startsWith('../')) {
28+
return false;
29+
}
30+
// Filter out Node.js built-ins (with or without node: prefix)
31+
if (specifier.startsWith('node:')) {
32+
return false;
33+
}
34+
if (nodeBuiltins.has(specifier) || optionalCjsExternal.has(specifier)) {
35+
return false;
36+
}
37+
return true;
38+
}
39+
40+
/**
41+
* Find and replace all third-party CJS requires with local entry files
42+
* Returns the modified source code and the set of third-party modules found
43+
*/
44+
export async function replaceThirdPartyCjsRequires(
45+
source: string,
46+
filePath: string,
47+
tsdownExternal: Set<string>,
48+
): Promise<{ code: string; modules: Set<string> }> {
49+
const ast = await parse(filePath, source, {
50+
lang: 'js',
51+
sourceType: 'module',
52+
});
53+
54+
const thirdPartyModules = new Set<string>();
55+
56+
// Find all createRequire patterns and their require calls
57+
const results = [
58+
findCreateRequireInStaticImports(ast),
59+
findCreateRequireInGlobalModule(ast),
60+
].filter((result): result is { requireVarName: string; calls: RequireCall[] } => Boolean(result));
61+
62+
// Collect all third-party require calls
63+
const replacements: RequireCall[] = [];
64+
for (const { calls } of results) {
65+
for (const call of calls) {
66+
if (isThirdPartyModule(call.module)) {
67+
const parts = call.module.split('/');
68+
const moduleName = call.module.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
69+
if (!tsdownExternal.has(moduleName)) {
70+
thirdPartyModules.add(call.module);
71+
replacements.push(call);
72+
}
73+
}
74+
}
75+
}
76+
77+
// Sort by position descending (process from end to start to avoid offset issues)
78+
replacements.sort((a, b) => b.start - a.start);
79+
80+
// Perform replacements
81+
let code = source;
82+
for (const { module, start, end } of replacements) {
83+
const newSpecifier = `"./${createModuleEntryFileName(module)}"`;
84+
code = code.slice(0, start) + newSpecifier + code.slice(end);
85+
}
86+
87+
return { code, modules: thirdPartyModules };
88+
}
89+
90+
interface RequireCall {
91+
module: string;
92+
start: number;
93+
end: number;
94+
}
95+
96+
/**
97+
* Find all calls to a specific require function and return the module specifiers with positions
98+
*/
99+
function findRequireCalls(ast: ParseResult, requireVarName: string): RequireCall[] {
100+
const calls: RequireCall[] = [];
101+
102+
const visitor = new Visitor({
103+
CallExpression(node: CallExpression) {
104+
// Check if callee is the require variable
105+
if (node.callee.type !== 'Identifier' || node.callee.name !== requireVarName) {
106+
return;
107+
}
108+
109+
// Extract the first argument (module specifier)
110+
if (node.arguments.length === 0) {
111+
return;
112+
}
113+
const arg = node.arguments[0];
114+
if (arg.type !== 'Literal') {
115+
return;
116+
}
117+
const value = (arg as { value: unknown; start: number; end: number }).value;
118+
if (typeof value === 'string') {
119+
calls.push({
120+
module: value,
121+
start: arg.start,
122+
end: arg.end,
123+
});
124+
}
125+
},
126+
});
127+
128+
visitor.visit(ast.program);
129+
return calls;
130+
}
131+
132+
/**
133+
* Find createRequire from static imports and return the require variable name + all require calls
134+
* Handles: `import { createRequire } from "node:module"` then `const require = createRequire(...)`
135+
*/
136+
function findCreateRequireInStaticImports(
137+
ast: ParseResult,
138+
): { requireVarName: string; calls: RequireCall[] } | undefined {
139+
// Find import from 'module' or 'node:module'
140+
const importFromModule = ast.module.staticImports.find((imt) => {
141+
const { value } = imt.moduleRequest;
142+
return value === 'node:module' || value === 'module';
143+
});
144+
if (!importFromModule) {
145+
return;
146+
}
147+
148+
// Find the createRequire import entry
149+
const createRequireEntry = importFromModule.entries.find((entry) => {
150+
return entry.importName.name === 'createRequire';
151+
});
152+
if (!createRequireEntry) {
153+
return;
154+
}
155+
156+
const createRequireLocalName = createRequireEntry.localName.value;
157+
158+
// Find the variable that stores the result of createRequire(...)
159+
// e.g., `const __require = createRequire(import.meta.url)`
160+
let requireVarName: string | undefined;
161+
162+
const varVisitor = new Visitor({
163+
VariableDeclarator(node: VariableDeclarator) {
164+
if (!node.init || node.init.type !== 'CallExpression') {
165+
return;
166+
}
167+
const call = node.init as CallExpression;
168+
if (call.callee.type === 'Identifier' && call.callee.name === createRequireLocalName) {
169+
if (node.id.type === 'Identifier') {
170+
requireVarName = node.id.name;
171+
}
172+
}
173+
},
174+
});
175+
varVisitor.visit(ast.program);
176+
177+
if (!requireVarName) {
178+
return;
179+
}
180+
181+
// Find all calls to the require variable
182+
const calls = findRequireCalls(ast, requireVarName);
183+
184+
return { requireVarName, calls };
185+
}
186+
187+
// Helper to check if an expression is `process` or `globalThis.process`
188+
function isProcessExpression(expr: Expression): boolean {
189+
// Check for `process`
190+
if (expr.type === 'Identifier' && expr.name === 'process') {
191+
return true;
192+
}
193+
// Check for `globalThis.process`
194+
if (expr.type === 'MemberExpression' && !expr.computed) {
195+
const memberExpr = expr as StaticMemberExpression;
196+
return (
197+
memberExpr.object.type === 'Identifier' &&
198+
memberExpr.object.name === 'globalThis' &&
199+
memberExpr.property.name === 'process'
200+
);
201+
}
202+
return false;
203+
}
204+
205+
// Helper to check if a CallExpression is `[process|globalThis.process].getBuiltinModule("module")`
206+
function isGetBuiltinModuleCall(expr: Expression): boolean {
207+
if (expr.type !== 'CallExpression') {
208+
return false;
209+
}
210+
const call = expr as CallExpression;
211+
212+
// Check callee is a member expression with property `getBuiltinModule`
213+
if (call.callee.type !== 'MemberExpression' || call.callee.computed) {
214+
return false;
215+
}
216+
const callee = call.callee as StaticMemberExpression;
217+
if (callee.property.name !== 'getBuiltinModule') {
218+
return false;
219+
}
220+
221+
// Check the object is `process` or `globalThis.process`
222+
if (!isProcessExpression(callee.object)) {
223+
return false;
224+
}
225+
226+
// Check argument is "module" or "node:module"
227+
if (call.arguments.length === 0) {
228+
return false;
229+
}
230+
const arg = call.arguments[0];
231+
if (arg.type !== 'Literal') {
232+
return false;
233+
}
234+
const value = (arg as { value: unknown }).value;
235+
return value === 'module' || value === 'node:module';
236+
}
237+
238+
/**
239+
* Find createRequire from getBuiltinModule and return the require variable name + all require calls
240+
* Handles: `const require = globalThis.process.getBuiltinModule("module").createRequire(import.meta.url)`
241+
* Or: `const require = process.getBuiltinModule("module").createRequire(import.meta.url)`
242+
*/
243+
function findCreateRequireInGlobalModule(
244+
ast: ParseResult,
245+
): { requireVarName: string; calls: RequireCall[] } | undefined {
246+
let requireVarName: string | undefined;
247+
248+
const visitor = new Visitor({
249+
VariableDeclarator(node: VariableDeclarator) {
250+
if (!node.init || node.init.type !== 'CallExpression') {
251+
return;
252+
}
253+
254+
const call = node.init as CallExpression;
255+
256+
// Check if callee is a MemberExpression with property `createRequire`
257+
if (call.callee.type !== 'MemberExpression' || call.callee.computed) {
258+
return;
259+
}
260+
const callee = call.callee as StaticMemberExpression;
261+
if (callee.property.name !== 'createRequire') {
262+
return;
263+
}
264+
265+
// Check if the object is a getBuiltinModule("module") call
266+
if (!isGetBuiltinModuleCall(callee.object)) {
267+
return;
268+
}
269+
270+
// Extract variable name
271+
if (node.id.type === 'Identifier') {
272+
requireVarName = node.id.name;
273+
}
274+
},
275+
});
276+
277+
visitor.visit(ast.program);
278+
279+
if (!requireVarName) {
280+
return;
281+
}
282+
283+
// Find all calls to the require variable
284+
const calls = findRequireCalls(ast, requireVarName);
285+
286+
return { requireVarName, calls };
287+
}

packages/core/build-support/rewrite-module-specifiers.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,7 @@ export function rewriteModuleSpecifiers(
124124
const processedRanges = new Set<string>();
125125

126126
// Find all import/export statements, call expressions, and ambient module declarations
127-
const nodeKinds = [
128-
'import_statement',
129-
'export_statement',
130-
'call_expression',
131-
];
127+
const nodeKinds = ['import_statement', 'export_statement', 'call_expression'];
132128

133129
// Add TypeScript-specific kinds for .d.ts files
134130
if (lang === Lang.TypeScript || lang === Lang.Tsx) {
@@ -142,7 +138,11 @@ export function rewriteModuleSpecifiers(
142138
// For call expressions, check if it's require/__require/import()
143139
if (kindName === 'call_expression') {
144140
const text = match.text();
145-
if (!text.startsWith('require(') && !text.startsWith('__require(') && !text.startsWith('import(')) {
141+
if (
142+
!text.startsWith('require(') &&
143+
!text.startsWith('__require(') &&
144+
!text.startsWith('import(')
145+
) {
146146
continue;
147147
}
148148
}

0 commit comments

Comments
 (0)