diff --git a/packages/core/src/nodes-loader/__tests__/custom-directory-loader.hot-reload.test.ts b/packages/core/src/nodes-loader/__tests__/custom-directory-loader.hot-reload.test.ts new file mode 100644 index 00000000000..125eef42954 --- /dev/null +++ b/packages/core/src/nodes-loader/__tests__/custom-directory-loader.hot-reload.test.ts @@ -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 `/node_modules/` 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/`. + 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); + }); +}); diff --git a/packages/core/src/nodes-loader/directory-loader.ts b/packages/core/src/nodes-loader/directory-loader.ts index 95fb5ecc0dc..c8c9ae5f520 100644 --- a/packages/core/src/nodes-loader/directory-loader.ts +++ b/packages/core/src/nodes-loader/directory-loader.ts @@ -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 + // `/node_modules/`. 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 `/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. + } + } }