mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
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:
parent
798268ac83
commit
f727ff261d
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user