Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 119 additions & 16 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -95,39 +97,105 @@ 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<string, number>()
private entries = new Map<string, TrackedFile>()

/**
* 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
}
}

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)
}
Expand Down Expand Up @@ -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<string, NodeJS.Timeout>()
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
}

Expand All @@ -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?.()
}
Expand All @@ -898,17 +997,21 @@ 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
}

if (RESTART_RE.test(file)) {
onReload(resolve(configDir, file))
}
})
configDirWatcher.on('change', listener)

return () => configDirWatcher.close()
return () => {
cancel()
configDirWatcher.close()
}
}

function isPublicHostname(hostname: string | undefined): boolean {
Expand Down
39 changes: 33 additions & 6 deletions packages/nuxt-cli/test/unit/file-watcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
})
})
Expand Down
48 changes: 48 additions & 0 deletions packages/nuxt-cli/test/unit/per-file.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading