diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 2d689a7a..9c773c50 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -1,15 +1,17 @@ import type { Nuxt, NuxtConfig, NuxtOptions, ViteConfig } from '@nuxt/schema' import type { createDevServer } from 'nitro/builder' import type { NitroDevServer } from 'nitropack' -import type { FSWatcher } from 'node:fs' +import type { FSWatcher, Stats } from 'node:fs' import type { Server as HttpServer, IncomingMessage, RequestListener, ServerResponse } from 'node:http' import type { ResolvedCertificate } from './cert' import type { InspectOptions } from './inspect' import type { DevListenOverrides, Listener, ListenOptions } from './listen' import type { DevRestartReason } from './reason' +import { Buffer } from 'node:buffer' +import { hash } from 'node:crypto' import EventEmitter from 'node:events' -import { existsSync, readdirSync, statSync, watch } from 'node:fs' +import { closeSync, existsSync, openSync, readdirSync, readSync, statSync, watch } from 'node:fs' import { mkdir } from 'node:fs/promises' import process from 'node:process' @@ -95,24 +97,90 @@ function devForkParentPid(): number | undefined { const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ const TRAILING_SLASH_RE = /\/$/ +/** + * Files above this size are tracked by mtime alone. + */ +const MAX_HASHED_FILE_SIZE = 256 * 1024 + +interface TrackedFile { + mtimeMs: number + /** Absent for directories and for files too large to hash. */ + contentHash?: string +} + +function hashFileContents(path: string, size: number): string | undefined { + if (size > MAX_HASHED_FILE_SIZE) { + return undefined + } + let fd: number | undefined + try { + fd = openSync(path, 'r') + // The stat'd size can be stale, so cap the read rather than trusting it; an + // extra byte means the file outgrew the limit and falls back to mtime. + const buffer = Buffer.allocUnsafe(MAX_HASHED_FILE_SIZE + 1) + let read = 0 + while (read < buffer.length) { + const bytes = readSync(fd, buffer, read, buffer.length - read, read) + if (bytes === 0) { + break + } + read += bytes + } + if (read > MAX_HASHED_FILE_SIZE) { + return undefined + } + return hash('sha1', buffer.subarray(0, read), 'hex') + } + catch { + return undefined + } + finally { + if (fd !== undefined) { + try { + closeSync(fd) + } + catch {} + } + } +} + +function trackFile(path: string, stats: Stats): TrackedFile { + if (stats.isDirectory()) { + return { mtimeMs: stats.mtimeMs } + } + return { mtimeMs: stats.mtimeMs, contentHash: hashFileContents(path, stats.size) } +} + export class FileChangeTracker { - private mtimes = new Map() + private entries = new Map() + /** + * Whether a watcher event for `filePath` represents a real change. + * + * Regular files are compared by content, so identical rewrites (atomic saves, + * formatters, `git checkout` of the same revision) do not trigger a reload. + * Directories and files over `MAX_HASHED_FILE_SIZE` fall back to mtime. + */ shouldEmitChange(filePath: string): boolean { const resolved = resolve(filePath) try { const stats = statSync(resolved) - const currentMtime = stats.mtimeMs - const lastMtime = this.mtimes.get(resolved) + const previous = this.entries.get(resolved) + const current = trackFile(resolved, stats) - this.mtimes.set(resolved, currentMtime) + this.entries.set(resolved, current) - // emit change for new file or mtime has changed - return lastMtime === undefined || currentMtime !== lastMtime + if (previous === undefined) { + return true + } + if (previous.contentHash !== undefined && current.contentHash !== undefined) { + return previous.contentHash !== current.contentHash + } + return previous.mtimeMs !== current.mtimeMs } catch { // remove from cache if it has been deleted or is inaccessible - this.mtimes.delete(resolved) + this.entries.delete(resolved) return true } } @@ -120,14 +188,14 @@ export class FileChangeTracker { prime(filePath: string, recursive: boolean = false): void { const resolved = resolve(filePath) const stat = statSync(resolved) - this.mtimes.set(resolved, stat.mtimeMs) + this.entries.set(resolved, trackFile(resolved, stat)) if (stat.isDirectory()) { const entries = readdirSync(resolved) for (const entry of entries) { const fullPath = resolve(resolved, entry) try { const stats = statSync(fullPath) - this.mtimes.set(fullPath, stats.mtimeMs) + this.entries.set(fullPath, trackFile(fullPath, stats)) if (recursive && stats.isDirectory()) { this.prime(fullPath, recursive) } @@ -864,14 +932,43 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '. } } +/** + * Collapse the burst of watcher events a single save produces into one call per + * file. A truncate-then-write save is briefly observable as an empty file, and + * evaluating it mid-write would report a spurious change. + */ +export function perFile(handler: (file: string) => void, delay = 30): { listener: (event: unknown, file: string | null) => void, cancel: () => void } { + const timers = new Map() + return { + listener: (_event, file) => { + if (!file) { + return + } + clearTimeout(timers.get(file)) + const timer = setTimeout(() => { + timers.delete(file) + handler(file) + }, delay) + timer.unref?.() + timers.set(file, timer) + }, + cancel: () => { + for (const timer of timers.values()) { + clearTimeout(timer) + } + timers.clear() + }, + } +} + function watchConfigDir(dir: string, onReload: (path: string) => void, onFile?: (file: string, path: string) => void) { const fileWatcher = new FileChangeTracker() fileWatcher.prime(dir) const watcher = watch(dir) let configDirWatcher = existsSync(join(dir, '.config')) ? createConfigDirWatcher(dir, onReload) : undefined - watcher.on('change', (_event, file: string | null) => { - if (!file || !fileWatcher.shouldEmitChange(resolve(dir, file))) { + const { listener, cancel } = perFile((file) => { + if (!fileWatcher.shouldEmitChange(resolve(dir, file))) { return } @@ -885,8 +982,10 @@ function watchConfigDir(dir: string, onReload: (path: string) => void, onFile?: configDirWatcher ||= createConfigDirWatcher(dir, onReload) } }) + watcher.on('change', listener) return () => { + cancel() watcher.close() configDirWatcher?.() } @@ -898,8 +997,8 @@ function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) { fileWatcher.prime(configDir) const configDirWatcher = watch(configDir) - configDirWatcher.on('change', (_event, file: string | null) => { - if (!file || !fileWatcher.shouldEmitChange(resolve(configDir, file))) { + const { listener, cancel } = perFile((file) => { + if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) { return } @@ -907,8 +1006,12 @@ function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) { onReload(resolve(configDir, file)) } }) + configDirWatcher.on('change', listener) - return () => configDirWatcher.close() + return () => { + cancel() + configDirWatcher.close() + } } function isPublicHostname(hostname: string | undefined): boolean { diff --git a/packages/nuxt-cli/test/unit/file-watcher.spec.ts b/packages/nuxt-cli/test/unit/file-watcher.spec.ts index a9ed9a98..14177551 100644 --- a/packages/nuxt-cli/test/unit/file-watcher.spec.ts +++ b/packages/nuxt-cli/test/unit/file-watcher.spec.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rename, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -145,20 +145,47 @@ describe('fileWatcher', () => { expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) }) - it('should detect mtime changes even with same content', async () => { + it('should ignore mtime changes when the content is unchanged', async () => { await writeFile(testFile, 'same content') - // First check expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + + const now = Date.now() + await utimes(testFile, new Date(now), new Date(now + 1000)) + + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + }) + + it('should ignore an atomic save that rewrites identical content', async () => { + await writeFile(testFile, 'export default {}\n') + fileWatcher.prime(testFile) + + const tempPath = `${testFile}.tmp` + await writeFile(tempPath, 'export default {}\n') + await rename(tempPath, testFile) - // No change expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + }) + + it('should detect an atomic save that changes content', async () => { + await writeFile(testFile, 'export default {}\n') + fileWatcher.prime(testFile) + + const tempPath = `${testFile}.tmp` + await writeFile(tempPath, 'export default { ssr: false }\n') + await rename(tempPath, testFile) + + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + }) + + it('should fall back to mtime for files too large to hash', async () => { + await writeFile(testFile, 'x'.repeat(300 * 1024)) + fileWatcher.prime(testFile) - // Manually update mtime to simulate file modification const now = Date.now() await utimes(testFile, new Date(now), new Date(now + 1000)) - // Should detect the mtime change expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) }) }) diff --git a/packages/nuxt-cli/test/unit/per-file.spec.ts b/packages/nuxt-cli/test/unit/per-file.spec.ts new file mode 100644 index 00000000..921de996 --- /dev/null +++ b/packages/nuxt-cli/test/unit/per-file.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' + +import { perFile } from '../../src/dev/utils' + +describe('perFile', () => { + it('should collapse a burst of events for one file into a single call', async () => { + vi.useFakeTimers() + const handler = vi.fn() + const { listener } = perFile(handler, 30) + + listener('change', 'nuxt.config.ts') + listener('rename', 'nuxt.config.ts') + listener('change', 'nuxt.config.ts') + + await vi.advanceTimersByTimeAsync(40) + + expect(handler).toHaveBeenCalledExactlyOnceWith('nuxt.config.ts') + vi.useRealTimers() + }) + + it('should keep separate files independent', async () => { + vi.useFakeTimers() + const handler = vi.fn() + const { listener } = perFile(handler, 30) + + listener('change', 'nuxt.config.ts') + listener('change', '.env') + + await vi.advanceTimersByTimeAsync(40) + + expect(handler.mock.calls.map(([file]) => file).sort()).toEqual(['.env', 'nuxt.config.ts']) + vi.useRealTimers() + }) + + it('should not call the handler after cancel', async () => { + vi.useFakeTimers() + const handler = vi.fn() + const { listener, cancel } = perFile(handler, 30) + + listener('change', '.env') + cancel() + + await vi.advanceTimersByTimeAsync(40) + + expect(handler).not.toHaveBeenCalled() + vi.useRealTimers() + }) +})