fix(core): Unloading a community package should also unload all its files from require.cache (#16072)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-06-05 21:48:19 +02:00
committed by GitHub
parent dfdc2237af
commit 8c63ca7d57
3 changed files with 39 additions and 12 deletions

View File

@@ -523,22 +523,15 @@ export class LoadNodesAndCredentials {
const push = Container.get(Push); const push = Container.get(Push);
Object.values(this.loaders).forEach(async (loader) => { Object.values(this.loaders).forEach(async (loader) => {
const { directory } = loader;
try { try {
await fsPromises.access(loader.directory); await fsPromises.access(directory);
} catch { } catch {
// If directory doesn't exist, there is nothing to watch // If directory doesn't exist, there is nothing to watch
return; return;
} }
const realModulePath = path.join(await fsPromises.realpath(loader.directory), path.sep);
const reloader = debounce(async () => { const reloader = debounce(async () => {
const modulesToUnload = Object.keys(require.cache).filter((filePath) =>
filePath.startsWith(realModulePath),
);
modulesToUnload.forEach((filePath) => {
delete require.cache[filePath];
});
loader.reset(); loader.reset();
await loader.loadAll(); await loader.loadAll();
await this.postProcessLoaders(); await this.postProcessLoaders();
@@ -549,11 +542,11 @@ export class LoadNodesAndCredentials {
? ['**/nodes.json', '**/credentials.json'] ? ['**/nodes.json', '**/credentials.json']
: ['**/*.js', '**/*.json']; : ['**/*.js', '**/*.json'];
const files = await glob(toWatch, { const files = await glob(toWatch, {
cwd: realModulePath, cwd: directory,
ignore: ['node_modules/**'], ignore: ['node_modules/**'],
}); });
const watcher = watch(files, { const watcher = watch(files, {
cwd: realModulePath, cwd: directory,
ignoreInitial: true, ignoreInitial: true,
}); });
watcher.on('add', reloader).on('change', reloader).on('unlink', reloader); watcher.on('add', reloader).on('change', reloader).on('unlink', reloader);

View File

@@ -13,6 +13,7 @@ jest.mock('node:fs');
jest.mock('node:fs/promises'); jest.mock('node:fs/promises');
const mockFs = mock<typeof fs>(); const mockFs = mock<typeof fs>();
const mockFsPromises = mock<typeof fsPromises>(); const mockFsPromises = mock<typeof fsPromises>();
fs.realpathSync = mockFs.realpathSync;
fs.readFileSync = mockFs.readFileSync; fs.readFileSync = mockFs.readFileSync;
fsPromises.readFile = mockFsPromises.readFile; fsPromises.readFile = mockFsPromises.readFile;
@@ -64,6 +65,7 @@ describe('DirectoryLoader', () => {
let mockCredential1: ICredentialType, mockNode1: INodeType, mockNode2: INodeType; let mockCredential1: ICredentialType, mockNode1: INodeType, mockNode2: INodeType;
beforeEach(() => { beforeEach(() => {
mockFs.realpathSync.mockImplementation((path) => String(path));
mockCredential1 = createCredential('credential1'); mockCredential1 = createCredential('credential1');
mockNode1 = createNode('node1', 'credential1'); mockNode1 = createNode('node1', 'credential1');
mockNode2 = createNode('node2'); mockNode2 = createNode('node2');
@@ -330,6 +332,19 @@ describe('DirectoryLoader', () => {
}); });
}); });
describe('constructor', () => {
it('should resolve symlinks to real paths when directory is a symlink', () => {
const symlinkPath = '/symlink/path';
const realPath = '/real/path';
mockFs.realpathSync.mockReturnValueOnce(realPath);
const loader = new CustomDirectoryLoader(symlinkPath);
expect(mockFs.realpathSync).toHaveBeenCalledWith(symlinkPath);
expect(loader.directory).toBe(realPath);
});
});
describe('reset()', () => { describe('reset()', () => {
it('should reset all properties to their initial state', async () => { it('should reset all properties to their initial state', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson); mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);

View File

@@ -17,6 +17,7 @@ import type {
KnownNodesAndCredentials, KnownNodesAndCredentials,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, isSubNodeType } from 'n8n-workflow'; import { ApplicationError, isSubNodeType } from 'n8n-workflow';
import { realpathSync } from 'node:fs';
import * as path from 'path'; import * as path from 'path';
import { UnrecognizedCredentialTypeError } from '@/errors/unrecognized-credential-type.error'; import { UnrecognizedCredentialTypeError } from '@/errors/unrecognized-credential-type.error';
@@ -83,13 +84,22 @@ export abstract class DirectoryLoader {
readonly directory: string, readonly directory: string,
protected excludeNodes: string[] = [], protected excludeNodes: string[] = [],
protected includeNodes: string[] = [], protected includeNodes: string[] = [],
) {} ) {
// If `directory` is a symlink, we try to resolve it to its real path
try {
this.directory = realpathSync(directory);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error.code !== 'ENOENT') throw error;
}
}
abstract packageName: string; abstract packageName: string;
abstract loadAll(): Promise<void>; abstract loadAll(): Promise<void>;
reset() { reset() {
this.unloadAll();
this.loadedNodes = []; this.loadedNodes = [];
this.nodeTypes = {}; this.nodeTypes = {};
this.credentialTypes = {}; this.credentialTypes = {};
@@ -450,4 +460,13 @@ export abstract class DirectoryLoader {
return; return;
} }
private unloadAll() {
const filesToUnload = Object.keys(require.cache).filter((filePath) =>
filePath.startsWith(this.directory),
);
filesToUnload.forEach((filePath) => {
delete require.cache[filePath];
});
}
} }