fix(node-cli): Watch asset roots created after dev starts

Narrowing the static asset watcher to the directories that exist at
startup dropped the one property the previous recursive watch on the
project root had: a directory created later was still picked up. A
project that adds its first root icons folder mid-session got no
watcher, so new SVGs were not copied or reloaded until restart.

Watch the project root non-recursively when an asset root is missing,
and attach the real watcher once it appears. That costs a single
descriptor rather than one per subdirectory.
This commit is contained in:
Garrit Franke 2026-09-15 10:43:07 +02:00 committed by n8n-assistant[bot]
parent 054f4dfb20
commit bcf815fda0
2 changed files with 93 additions and 14 deletions

View File

@ -1,5 +1,5 @@
import { type ChildProcess, execSync, spawn } from 'node:child_process';
import { existsSync, type FSWatcher, watch } from 'node:fs';
import { type FSWatcher, statSync, watch } from 'node:fs';
import path from 'node:path';
import {
@ -41,6 +41,13 @@ function createFakeWatcher() {
return { unref: vi.fn(), close: vi.fn() };
}
function mockDirs(isDir: (target: string) => boolean): void {
vi.mocked(statSync).mockImplementation((target) => {
if (!isDir(String(target))) throw new Error('ENOENT');
return { isDirectory: () => true } as unknown as ReturnType<typeof statSync>;
});
}
describe('dev utils', () => {
describe('sleep', () => {
beforeEach(() => {
@ -301,7 +308,7 @@ describe('dev utils', () => {
});
it('should watch only the asset directories, never node_modules or the cwd', () => {
vi.mocked(existsSync).mockReturnValue(true);
mockDirs(() => true);
vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher);
watchStaticFiles(vi.fn());
@ -316,20 +323,67 @@ describe('dev utils', () => {
expect(watched.some((dir) => String(dir).includes('node_modules'))).toBe(false);
});
it('should skip asset directories that do not exist', () => {
vi.mocked(existsSync).mockImplementation((target) => String(target).endsWith('nodes'));
it('should not watch asset directories that do not exist', () => {
mockDirs((target) => target.endsWith('nodes'));
vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher);
watchStaticFiles(vi.fn());
expect(watch).toHaveBeenCalledTimes(1);
expect(vi.mocked(watch).mock.calls[0][0]).toBe(path.join(process.cwd(), 'nodes'));
const watched = vi.mocked(watch).mock.calls.map((call) => String(call[0]));
expect(watched).toContain(path.join(process.cwd(), 'nodes'));
expect(watched).not.toContain(path.join(process.cwd(), 'icons'));
});
it('should watch an asset directory created after startup', () => {
let exists = (target: string) => target.endsWith('nodes');
mockDirs((target) => exists(target));
vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher);
const onChange = vi.fn();
watchStaticFiles(onChange);
// Only the root watcher can see a new asset root appear.
const rootCall = vi
.mocked(watch)
.mock.calls.find((call) => String(call[0]) === process.cwd());
expect(rootCall).toBeDefined();
const rootListener = rootCall?.[1] as unknown as (
event: string,
filename: string | null,
) => void;
exists = (target) => target.endsWith('nodes') || target.endsWith('icons');
rootListener('rename', 'icons');
const watched = vi.mocked(watch).mock.calls.map((call) => String(call[0]));
expect(watched).toContain(path.join(process.cwd(), 'icons'));
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should ignore a non-directory appearing with an asset directory name', () => {
mockDirs((target) => target.endsWith('nodes'));
vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher);
watchStaticFiles(vi.fn());
const rootListener = vi
.mocked(watch)
.mock.calls.find((call) => String(call[0]) === process.cwd())?.[1] as unknown as (
event: string,
filename: string | null,
) => void;
// `icons` exists as a file, so statSync never reports a directory.
expect(() => rootListener('rename', 'icons')).not.toThrow();
const watched = vi.mocked(watch).mock.calls.map((call) => String(call[0]));
expect(watched).not.toContain(path.join(process.cwd(), 'icons'));
});
it('should close every watcher on cleanup', () => {
const watchers = [createFakeWatcher(), createFakeWatcher(), createFakeWatcher()];
let index = 0;
vi.mocked(existsSync).mockReturnValue(true);
mockDirs(() => true);
vi.mocked(watch).mockImplementation(() => watchers[index++] as unknown as FSWatcher);
watchStaticFiles(vi.fn())();
@ -341,7 +395,7 @@ describe('dev utils', () => {
it('should only react to static asset changes', () => {
const onChange = vi.fn();
vi.mocked(existsSync).mockImplementation((target) => String(target).endsWith('nodes'));
mockDirs((target) => target.endsWith('nodes'));
vi.mocked(watch).mockImplementation(() => createFakeWatcher() as unknown as FSWatcher);
watchStaticFiles(onChange);

View File

@ -1,5 +1,5 @@
import { type ChildProcess, execSync, spawn, type SpawnOptions } from 'node:child_process';
import { existsSync, watch } from 'node:fs';
import { type FSWatcher, statSync, watch } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import picocolors from 'picocolors';
@ -684,19 +684,44 @@ const STATIC_ASSET_DIRS = ['nodes', 'credentials', 'icons'];
* without this an icon or schema edit never reaches `dist`.
*/
export function watchStaticFiles(onChange: () => void): () => void {
const watchers = STATIC_ASSET_DIRS.flatMap((dir) => {
const watchers: FSWatcher[] = [];
const watchAssetDir = (dir: string): boolean => {
const target = path.join(process.cwd(), dir);
if (!existsSync(target)) return [];
try {
if (!statSync(target).isDirectory()) return false;
} catch {
return false;
}
// `filename` is relative to `target`, which the pattern already allows for.
return watch(target, { recursive: true }, (_event, filename) => {
const watcher = watch(target, { recursive: true }, (_event, filename) => {
if (!filename) return;
if (!STATIC_ASSET_PATTERN.test(filename)) return;
onChange();
});
});
watcher.unref();
watchers.push(watcher);
return true;
};
const missing = new Set(STATIC_ASSET_DIRS.filter((dir) => !watchAssetDir(dir)));
// An asset root created after startup would otherwise stay unwatched until a
// restart. One non-recursive watch on the project root costs a single
// descriptor, where watching the tree would cost one per subdirectory.
if (missing.size > 0) {
const rootWatcher = watch(process.cwd(), (_event, filename) => {
if (!filename || !missing.has(filename)) return;
if (!watchAssetDir(filename)) return;
missing.delete(filename);
// The directory can already hold assets by the time the watcher attaches.
onChange();
});
rootWatcher.unref();
watchers.push(rootWatcher);
}
for (const watcher of watchers) watcher.unref();
return () => {
for (const watcher of watchers) watcher.close();
};