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
41 changes: 39 additions & 2 deletions packages/nuxi/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http
import type { AddressInfo } from 'node:net'

import EventEmitter from 'node:events'
import { existsSync, watch } from 'node:fs'
import { existsSync, statSync, watch } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
Expand Down Expand Up @@ -73,6 +73,28 @@ interface NuxtDevServerOptions {
// https://regex101.com/r/7HkR5c/1
const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/

export class FileChangeTracker {
private mtimes = new Map<string, number>()

shouldEmitChange(filePath: string): boolean {
try {
const stats = statSync(filePath)
const currentMtime = stats.mtimeMs
const lastMtime = this.mtimes.get(filePath)

this.mtimes.set(filePath, currentMtime)

// emit change for new file or mtime has changed
return lastMtime === undefined || currentMtime !== lastMtime
}
catch {
// remove from cache if it has been deleted or is inaccessible
this.mtimes.delete(filePath)
return true
}
}
}

type NuxtWithServer = Omit<Nuxt, 'server'> & { server?: NitroDevServer }

interface DevServerEventMap {
Expand All @@ -89,6 +111,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
private _currentNuxt?: NuxtWithServer
private _loadingMessage?: string
private _loadingError?: Error
private _fileChangeTracker = new FileChangeTracker()
private cwd: string

loadDebounced: (reload?: boolean, reason?: string) => void
Expand Down Expand Up @@ -310,7 +333,11 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
const distDir = resolve(this._currentNuxt.options.buildDir, 'dist')
await mkdir(distDir, { recursive: true })
this._distWatcher = watch(distDir)
this._distWatcher.on('change', () => {
this._distWatcher.on('change', (_event, file: string) => {
if (!this._fileChangeTracker.shouldEmitChange(resolve(distDir, file || ''))) {
return
}

this.loadDebounced(true, '.nuxt/dist directory has been removed')
})

Expand Down Expand Up @@ -374,8 +401,13 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '.
const configWatcher = watch(cwd)
let configDirWatcher = existsSync(resolve(cwd, '.config')) ? createConfigDirWatcher(cwd, onReload) : undefined
const dotenvFileNames = new Set(Array.isArray(dotenvFileName) ? dotenvFileName : [dotenvFileName])
const fileWatcher = new FileChangeTracker()

configWatcher.on('change', (_event, file: string) => {
if (!fileWatcher.shouldEmitChange(resolve(cwd, file))) {
return
}

if (dotenvFileNames.has(file)) {
onRestart()
}
Expand All @@ -397,9 +429,14 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '.

function createConfigDirWatcher(cwd: string, onReload: (file: string) => void) {
const configDir = resolve(cwd, '.config')
const fileWatcher = new FileChangeTracker()

const configDirWatcher = watch(configDir)
configDirWatcher.on('change', (_event, file: string) => {
if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) {
return
}

if (RESTART_RE.test(file)) {
onReload(file)
}
Expand Down
95 changes: 95 additions & 0 deletions packages/nuxi/test/unit/file-watcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { existsSync } from 'node:fs'
import { mkdtemp, 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'

import { FileChangeTracker } from '../../src/dev/utils'

describe('fileWatcher', () => {
let tempDir: string
let testFile: string
let fileWatcher: FileChangeTracker

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'nuxt-cli-test-'))
testFile = join(tempDir, 'test-config.js')
fileWatcher = new FileChangeTracker()
})

afterEach(async () => {
if (existsSync(tempDir)) {
await rm(tempDir, { recursive: true, force: true })
}
})

it('should return true for first check of a file', async () => {
await writeFile(testFile, 'initial content')

const shouldEmit = fileWatcher.shouldEmitChange(testFile)
expect(shouldEmit).toBe(true)
})

it('should return false when file has not been modified', async () => {
await writeFile(testFile, 'initial content')

// First call should return true (new file)
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)

// Second call without modification should return false
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)

// Third call still should return false
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
})

it('should return true when file has been modified', async () => {
await writeFile(testFile, 'initial content')

// First check
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)

// No modification - should return false
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)

// Wait a bit and modify the file
await new Promise(resolve => setTimeout(resolve, 10))
await writeFile(testFile, 'modified content')

// Should return true because file was modified
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)

// Subsequent check without modification should return false
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
})

it('should handle file deletion gracefully', async () => {
await writeFile(testFile, 'content')

// First check
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)

// Delete the file
await rm(testFile)

// Should return true when file is deleted (indicates change)
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
})

it('should detect mtime changes even with same content', async () => {
await writeFile(testFile, 'same content')

// First check
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)

// No change
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)

// 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)
})
})
Loading