Skip to content

Commit 7effd4a

Browse files
Make sure core modules are preloaded (#6134)
So that core modules get correctly mapped when an installed recipe depends upon them, we need to first preload them during recipe installation. Also, make sure that module mapping works correctly, regardless of whether the server was started using a globally installed `rewrite-rpc` script, a specific package version using `--package`, or the file system path to the compiled `server.js` script.
1 parent 27b8d9f commit 7effd4a

2 files changed

Lines changed: 158 additions & 24 deletions

File tree

rewrite-javascript/rewrite/src/rpc/request/install-recipes.ts

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ export class InstallRecipes {
118118

119119
let recipeModule;
120120
try {
121-
setupSharedDependencies(resolvedPath);
121+
// Pre-load core modules that are used by recipes but loaded lazily
122+
// This ensures they're in require.cache before setupSharedDependencies runs
123+
preloadCoreModules(logger);
124+
125+
setupSharedDependencies(resolvedPath, logger);
122126
recipeModule = require(resolvedPath);
123127
} catch (e: any) {
124128
throw new Error(`Failed to load recipe module from ${resolvedPath}: ${e.stack}`);
@@ -142,38 +146,137 @@ export class InstallRecipes {
142146
}
143147
}
144148

149+
/**
150+
* Pre-loads core modules that are typically loaded lazily by recipes.
151+
* This ensures they're in require.cache before setupSharedDependencies runs,
152+
* so they can be properly mapped to avoid instanceof failures.
153+
*/
154+
function preloadCoreModules(logger?: rpc.Logger) {
155+
const modulesToPreload = [
156+
'../..',
157+
'../../java',
158+
'../../javascript',
159+
'../../json',
160+
'../../rpc',
161+
'../../search',
162+
'../../text',
163+
];
164+
165+
modulesToPreload.forEach(modulePath => {
166+
try {
167+
require(modulePath);
168+
if (logger) {
169+
logger.info(`[preloadCoreModules] Loaded ${modulePath}`);
170+
}
171+
} catch (e) {
172+
if (logger) {
173+
logger.warn(`[preloadCoreModules] Failed to load ${modulePath}: ${e}`);
174+
}
175+
}
176+
});
177+
}
178+
145179
/**
146180
* Ensures dynamically loaded modules share the same class instances as the host
147181
* by mapping require.cache entries. This prevents instanceof failures when the
148182
* same package is installed in multiple node_modules directories.
149183
*/
150-
function setupSharedDependencies(targetModulePath: string) {
184+
function setupSharedDependencies(targetModulePath: string, logger?: rpc.Logger) {
151185
const sharedDeps = ['@openrewrite/rewrite', 'vscode-jsonrpc'];
152186
const targetDir = path.dirname(targetModulePath);
153187

154188
sharedDeps.forEach(depName => {
155-
const depPattern = path.sep + 'node_modules' + path.sep + depName.replace('/', path.sep);
156-
157-
for (const cachedPath of Object.keys(require.cache)) {
158-
if (!cachedPath.includes(depPattern)) continue;
159-
160-
try {
161-
// Extract subpath: /path/node_modules/@pkg/dist/tree.js -> dist/tree.js
162-
const pkgIndex = cachedPath.indexOf(depPattern);
163-
let subpath = cachedPath.substring(pkgIndex + depPattern.length)
164-
.replace(/^[/\\]/, '') // Remove leading slash
165-
.replace(/\.(js|ts)$/, '') // Remove extension
166-
.replace(/^dist[/\\]/, '') // Remove dist/ prefix if present
167-
.replace(/[/\\]index$/, ''); // Remove /index suffix
168-
169-
// Build require path: @pkg or @pkg/subpath
170-
const requirePath = subpath ? `${depName}/${subpath}` : depName;
171-
172-
// Resolve from target's perspective and map cache
173-
const targetDepPath = require.resolve(requirePath, {paths: [targetDir]});
174-
require.cache[targetDepPath] = require.cache[cachedPath];
175-
} catch (e) {
176-
// Target can't resolve this path, skip
189+
try {
190+
// Step 1: Find where this package is currently loaded from (host)
191+
const hostPackageEntry = require.resolve(depName);
192+
193+
// Step 2: Find the package root by looking for package.json
194+
let hostPackageRoot = path.dirname(hostPackageEntry);
195+
while (hostPackageRoot !== path.dirname(hostPackageRoot)) {
196+
const packageJsonPath = path.join(hostPackageRoot, 'package.json');
197+
if (fs.existsSync(packageJsonPath)) {
198+
try {
199+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
200+
if (pkg.name === depName) {
201+
break; // Found the package root
202+
}
203+
} catch (e) {
204+
// Not a valid package.json, continue
205+
}
206+
}
207+
hostPackageRoot = path.dirname(hostPackageRoot);
208+
}
209+
210+
if (logger) {
211+
logger.info(`[setupSharedDependencies] Host package root for ${depName}: ${hostPackageRoot}`);
212+
}
213+
214+
// Step 3: Find where the target's node_modules has this package
215+
// We explicitly look in node_modules to avoid finding npm-linked global packages
216+
let targetPackageRoot: string | undefined;
217+
218+
// Walk up from targetDir looking for node_modules containing this package
219+
let searchDir = targetDir;
220+
while (searchDir !== path.dirname(searchDir)) {
221+
const nodeModulesPath = path.join(searchDir, 'node_modules', ...depName.split('/'));
222+
if (fs.existsSync(nodeModulesPath)) {
223+
const packageJsonPath = path.join(nodeModulesPath, 'package.json');
224+
if (fs.existsSync(packageJsonPath)) {
225+
try {
226+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
227+
if (pkg.name === depName) {
228+
targetPackageRoot = nodeModulesPath;
229+
break;
230+
}
231+
} catch (e) {
232+
// Not a valid package.json, continue
233+
}
234+
}
235+
}
236+
searchDir = path.dirname(searchDir);
237+
}
238+
239+
if (!targetPackageRoot) {
240+
if (logger) {
241+
logger.warn(`[setupSharedDependencies] Could not find ${depName} in target's node_modules`);
242+
}
243+
return; // Can't map this package
244+
}
245+
246+
if (logger) {
247+
logger.info(`[setupSharedDependencies] Target package root for ${depName}: ${targetPackageRoot}`);
248+
}
249+
250+
// If they're the same, no mapping needed
251+
if (hostPackageRoot === targetPackageRoot) {
252+
if (logger) {
253+
logger.info(`[setupSharedDependencies] Same package root, no mapping needed for ${depName}`);
254+
}
255+
return;
256+
}
257+
258+
// Step 4: Map all cached modules from host package to target package
259+
const hostPrefix = hostPackageRoot + path.sep;
260+
261+
let mappedCount = 0;
262+
for (const cachedPath of Object.keys(require.cache)) {
263+
if (cachedPath.startsWith(hostPrefix)) {
264+
// This module belongs to the host package
265+
const relativePath = cachedPath.substring(hostPrefix.length);
266+
const targetPath = path.join(targetPackageRoot, relativePath);
267+
268+
// Map the target path to use the host's cached module
269+
require.cache[targetPath] = require.cache[cachedPath];
270+
mappedCount++;
271+
}
272+
}
273+
274+
if (logger) {
275+
logger.info(`[setupSharedDependencies] Mapped ${mappedCount} modules for ${depName}`);
276+
}
277+
} catch (e) {
278+
if (logger) {
279+
logger.error(`[setupSharedDependencies] Failed to setup ${depName}: ${e}`);
177280
}
178281
}
179282
});

rewrite-javascript/rewrite/test/rpc/shared-dependencies.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,35 @@ describe('Shared dependencies setup', () => {
9191
expect(afterPackage).toBe(tc.expected);
9292
});
9393
});
94+
95+
it('should handle modules not in package.json exports', () => {
96+
// This test documents the scenario where a module like 'preconditions'
97+
// is not in the package.json exports map, but is still needed for instanceof checks
98+
99+
// Simulating what happens when:
100+
// 1. RPC server loads Check from 'preconditions.ts'
101+
// 2. Recipe package tries to load Check but doesn't have './preconditions' in exports
102+
// 3. setupSharedDependencies() needs to map it anyway using fallback strategies
103+
104+
const mockPreconditionsPath = '/host/node_modules/@openrewrite/rewrite/dist/preconditions.js';
105+
106+
// The subpath extraction should work
107+
const depPattern = path.sep + 'node_modules' + path.sep + '@openrewrite/rewrite'.replace('/', path.sep);
108+
const pkgIndex = mockPreconditionsPath.indexOf(depPattern);
109+
let subpath = mockPreconditionsPath.substring(pkgIndex + depPattern.length)
110+
.replace(/^[/\\]/, '')
111+
.replace(/\.(js|ts)$/, '')
112+
.replace(/^dist[/\\]/, '')
113+
.replace(/[/\\]index$/, '');
114+
115+
expect(subpath).toBe('preconditions');
116+
117+
// Even if '@openrewrite/rewrite/preconditions' can't be resolved via exports,
118+
// the fallback strategy should construct the direct file path
119+
const targetDir = '/target/node_modules/@openrewrite/recipes-nodejs';
120+
const expectedFallbackPath = path.join(targetDir, 'node_modules', '@openrewrite', 'rewrite', 'dist', 'preconditions.js');
121+
122+
// This documents that the fallback strategy exists for such cases
123+
expect(expectedFallbackPath).toContain('preconditions.js');
124+
});
94125
});

0 commit comments

Comments
 (0)