fix: Reload symlinked community node changes without restart (#32774)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-06-24 10:54:17 +01:00 committed by GitHub
parent 798268ac83
commit f727ff261d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 196 additions and 2 deletions

View File

@ -0,0 +1,123 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CustomDirectoryLoader } from '../custom-directory-loader';
/**
* Reproduces NODE-5225: when a community node is developed with `n8n-node dev`,
* the package is symlinked into `<customDir>/node_modules/<pkg>` and rebuilt on
* change. Reloading the node (reset + loadAll) should serve the freshly compiled
* code, but the loader kept serving the previously loaded version until restart.
*/
describe('CustomDirectoryLoader hot reload (NODE-5225)', () => {
let tmpRoot: string;
let customDir: string;
const nodeSource = (version: number) => `
class Reloadable {
constructor() {
this.description = {
displayName: 'Reloadable',
name: 'reloadable',
group: ['transform'],
version: ${version},
description: 'v${version}',
defaults: { name: 'Reloadable' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
}
module.exports = { Reloadable };
`;
const writeNode = (dir: string, version: number) => {
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'Reloadable.node.js');
fs.writeFileSync(file, nodeSource(version));
return file;
};
// The node description type is a union; the fixture is a non-versioned node.
const loadedVersion = (loader: CustomDirectoryLoader) =>
(loader.getNode('reloadable').type.description as INodeTypeDescription).version;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-hot-reload-'));
customDir = path.join(tmpRoot, 'custom');
fs.mkdirSync(customDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it('serves the recompiled node after reset + loadAll (symlinked dev node)', async () => {
// The developer's node project, compiled to `dist`.
const projectDir = path.join(tmpRoot, 'my-node-project');
const nodeFile = writeNode(path.join(projectDir, 'dist', 'nodes'), 1);
// The project is symlinked into node_modules, exactly how `n8n-node dev`
// installs the node for live preview.
const customNodeModules = path.join(customDir, 'node_modules');
fs.mkdirSync(customNodeModules, { recursive: true });
fs.symlinkSync(projectDir, path.join(customNodeModules, 'n8n-nodes-reloadable'));
const loader = new CustomDirectoryLoader(customDir);
await loader.loadAll();
expect(loadedVersion(loader)).toBe(1);
// Simulate a rebuild by the watchdog (e.g. `tsc --watch`).
fs.writeFileSync(nodeFile, nodeSource(2));
// Reload the node the same way the hot-reload flow does.
loader.reset();
await loader.loadAll();
expect(loadedVersion(loader)).toBe(2);
});
it('serves the recompiled node after reset + loadAll (symlinked scoped dev node)', async () => {
// Scoped community node (`@scope/n8n-nodes-foo`): the package is symlinked
// one level deeper, under `node_modules/@scope/<pkg>`.
const projectDir = path.join(tmpRoot, 'my-scoped-node-project');
const nodeFile = writeNode(path.join(projectDir, 'dist', 'nodes'), 1);
const scopeDir = path.join(customDir, 'node_modules', '@scope');
fs.mkdirSync(scopeDir, { recursive: true });
fs.symlinkSync(projectDir, path.join(scopeDir, 'n8n-nodes-reloadable'));
const loader = new CustomDirectoryLoader(customDir);
await loader.loadAll();
expect(loadedVersion(loader)).toBe(1);
fs.writeFileSync(nodeFile, nodeSource(2));
loader.reset();
await loader.loadAll();
expect(loadedVersion(loader)).toBe(2);
});
it('serves the recompiled node after reset + loadAll (node placed directly in custom dir)', async () => {
// Classic `~/.n8n/custom` layout: no `node_modules`, no symlink.
const nodeFile = writeNode(path.join(customDir, 'Reloadable'), 1);
const loader = new CustomDirectoryLoader(customDir);
await loader.loadAll();
expect(loadedVersion(loader)).toBe(1);
fs.writeFileSync(nodeFile, nodeSource(2));
loader.reset();
await loader.loadAll();
expect(loadedVersion(loader)).toBe(2);
});
});

View File

@ -18,7 +18,7 @@ import type {
NodeLoader,
} from 'n8n-workflow';
import { isExpression, isSubNodeType, UnexpectedError, UserError } from 'n8n-workflow';
import { realpathSync } from 'node:fs';
import { readdirSync, realpathSync } from 'node:fs';
import * as path from 'path';
import { UnrecognizedCredentialTypeError } from '@/errors/unrecognized-credential-type.error';
@ -531,11 +531,82 @@ export abstract class DirectoryLoader implements NodeLoader {
}
private unloadAll() {
// Community nodes developed with `n8n-node dev` are symlinked into
// `<directory>/node_modules/<pkg>`. Node's require cache keys those files by
// their resolved real path (the symlink target), which lives outside
// `this.directory`, so we also sweep the resolved roots to pick up rebuilds.
const rootsToUnload = [this.directory, ...this.getSymlinkedPackageRoots()];
const filesToUnload = Object.keys(require.cache).filter((filePath) =>
filePath.startsWith(this.directory),
rootsToUnload.some((root) => filePath.startsWith(root)),
);
filesToUnload.forEach((filePath) => {
delete require.cache[filePath];
});
}
/** Resolves symlinked packages in `<directory>/node_modules` to their real paths. */
private getSymlinkedPackageRoots(): string[] {
const nodeModulesDir = path.join(this.directory, 'node_modules');
let entries;
try {
entries = readdirSync(nodeModulesDir, { withFileTypes: true });
} catch {
// No `node_modules` (e.g. lazy-loaded packages) - nothing to resolve.
return [];
}
if (!Array.isArray(entries)) return [];
const roots: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (!entry.isSymbolicLink() && !entry.isDirectory()) continue;
const entryPath = path.join(nodeModulesDir, entry.name);
// Scoped packages (`@scope/pkg`) symlink one level deeper: `@scope` is a
// real directory, so descend into it and resolve each scoped package.
if (entry.name.startsWith('@') && entry.isDirectory()) {
roots.push(...this.resolveSymlinkedRoots(entryPath));
continue;
}
this.pushResolvedRoot(roots, entryPath);
}
return roots;
}
/** Resolves symlinked packages directly under `scopeDir` to their real paths. */
private resolveSymlinkedRoots(scopeDir: string): string[] {
let entries;
try {
entries = readdirSync(scopeDir, { withFileTypes: true });
} catch {
return [];
}
if (!Array.isArray(entries)) return [];
const roots: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (!entry.isSymbolicLink() && !entry.isDirectory()) continue;
this.pushResolvedRoot(roots, path.join(scopeDir, entry.name));
}
return roots;
}
/** Adds `entryPath`'s real path to `roots` when it resolves through a symlink. */
private pushResolvedRoot(roots: string[], entryPath: string) {
try {
const realPath = realpathSync(entryPath);
if (realPath !== entryPath) roots.push(realPath);
} catch {
// Broken symlink - skip.
}
}
}