diff --git a/package-lock.json b/package-lock.json index 44ecef92acb..3efc7dc8677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6372,7 +6372,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6386,7 +6385,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6400,7 +6398,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6414,7 +6411,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6428,7 +6424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6442,7 +6437,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6456,7 +6450,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6470,7 +6463,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6484,7 +6476,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6498,7 +6489,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6512,7 +6502,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6526,7 +6515,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6540,7 +6528,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6554,7 +6541,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6568,7 +6554,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6582,7 +6567,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6596,7 +6580,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6610,7 +6593,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6624,7 +6606,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6638,7 +6619,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6652,7 +6632,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6666,7 +6645,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6680,7 +6658,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6694,7 +6671,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6708,7 +6684,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12860,7 +12835,6 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/commands/dev/dev.ts b/src/commands/dev/dev.ts index f17b4ef5ba1..0710f238120 100644 --- a/src/commands/dev/dev.ts +++ b/src/commands/dev/dev.ts @@ -319,6 +319,7 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { accountId, functionsRegistry, repositoryRoot, + watchIgnore: devConfig.watchIgnore ?? [], deployEnvironment, }) diff --git a/src/commands/dev/types.d.ts b/src/commands/dev/types.d.ts index 54b5ac6a15d..a6aada6efce 100644 --- a/src/commands/dev/types.d.ts +++ b/src/commands/dev/types.d.ts @@ -18,4 +18,5 @@ export type DevConfig = NonNullable & { jwtSecret?: string | undefined jwtRolePath?: string | undefined pollingStrategies?: string[] | undefined + watchIgnore?: string[] | undefined } diff --git a/src/commands/serve/serve.ts b/src/commands/serve/serve.ts index e52e88842cb..87d03f78464 100644 --- a/src/commands/serve/serve.ts +++ b/src/commands/serve/serve.ts @@ -210,6 +210,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => { siteInfo, state, accountId, + watchIgnore: devConfig.watchIgnore ?? [], deployEnvironment: [], }) diff --git a/src/lib/edge-functions/proxy.ts b/src/lib/edge-functions/proxy.ts index 09225c0b5d9..97326def833 100644 --- a/src/lib/edge-functions/proxy.ts +++ b/src/lib/edge-functions/proxy.ts @@ -95,6 +95,7 @@ export const initializeProxy = async ({ settings, siteInfo, state, + watchIgnore, deployEnvironment, }: { accountId: string @@ -117,6 +118,7 @@ export const initializeProxy = async ({ settings: ServerSettings siteInfo: $TSFixMe state: LocalState + watchIgnore: string[] deployEnvironment: { key: string; value: string; isSecret: boolean; scopes: string[] }[] }) => { const isolatePort = await getAvailablePort() @@ -139,7 +141,9 @@ export const initializeProxy = async ({ inspectSettings, port: isolatePort, projectDir, + publishDir: settings.dist, repositoryRoot, + watchIgnore, deployEnvironment, }) return async (req: ExtendedIncomingMessage) => { @@ -214,7 +218,9 @@ const prepareServer = async ({ inspectSettings, port, projectDir, + publishDir, repositoryRoot, + watchIgnore, deployEnvironment, }: { aiGatewayContext?: AIGatewayContext | null @@ -228,7 +234,9 @@ const prepareServer = async ({ inspectSettings: Parameters[0]['inspectSettings'] port: number projectDir: string + publishDir: string repositoryRoot?: string + watchIgnore: string[] deployEnvironment: { key: string; value: string; isSecret: boolean; scopes: string[] }[] }) => { try { @@ -267,8 +275,10 @@ const prepareServer = async ({ getUpdatedConfig, importMapFromTOML: config.functions?.['*'].deno_import_map, projectDir, + publishDir, runIsolate, servePath, + watchIgnore, deployEnvironment: deployEnvironment .filter(({ scopes }) => scopes.includes('functions')) // Scopes should be opaque to the functions registry: We just filtered down to only variables diff --git a/src/lib/edge-functions/registry.ts b/src/lib/edge-functions/registry.ts index 58b69b67149..f08e0ee76f4 100644 --- a/src/lib/edge-functions/registry.ts +++ b/src/lib/edge-functions/registry.ts @@ -1,5 +1,6 @@ import { readFile } from 'fs/promises' -import { join } from 'path' +import { statSync } from 'fs' +import { join, resolve } from 'path' import { fileURLToPath } from 'url' import type { Declaration, EdgeFunction, FunctionConfig, Manifest, ModuleGraph } from '@netlify/edge-bundler' @@ -48,8 +49,10 @@ interface EdgeFunctionsRegistryOptions { getUpdatedConfig: () => Promise importMapFromTOML?: string projectDir: string + publishDir: string runIsolate: RunIsolate servePath: string + watchIgnore: string[] deployEnvironment: { key: string; value: string; isSecret: boolean }[] } @@ -143,6 +146,8 @@ export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry { private routes: Route[] = [] private runIsolate: RunIsolate private servePath: string + private publishDir: string + private watchIgnore: string[] private projectDir: string private command: BaseCommand @@ -157,8 +162,10 @@ export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry { getUpdatedConfig, importMapFromTOML, projectDir, + publishDir, runIsolate, servePath, + watchIgnore, deployEnvironment, }: EdgeFunctionsRegistryOptions) { this.aiGatewayContext = aiGatewayContext @@ -169,6 +176,8 @@ export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry { this.getUpdatedConfig = getUpdatedConfig this.runIsolate = runIsolate this.servePath = servePath + this.publishDir = resolve(projectDir, publishDir) + this.watchIgnore = watchIgnore.map((p) => resolve(projectDir, p)) this.projectDir = projectDir this.importMapFromTOML = importMapFromTOML @@ -719,7 +728,23 @@ export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry { } private async setupWatcherForDirectory() { - const ignored = [`${this.servePath}/**`, this.internalImportMapPath] + const toIgnoredRegex = (dir: string) => new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/|$)`) + + const toIgnoredEntry = (p: string): string | RegExp => { + try { + if (statSync(p).isFile()) return p + } catch { + // path doesn't exist yet (e.g. publish dir before first build) — treat as directory + } + return toIgnoredRegex(p) + } + + const ignored: (string | RegExp)[] = [ + toIgnoredRegex(this.servePath), + ...(this.publishDir !== this.projectDir ? [toIgnoredRegex(this.publishDir)] : []), + ...this.watchIgnore.map(toIgnoredEntry), + this.internalImportMapPath, + ] const watcher = await watchDebounced(this.projectDir, { ignored, onAdd: () => this.checkForAddedOrDeletedFunctions(), diff --git a/src/utils/proxy-server.ts b/src/utils/proxy-server.ts index 1a1a274d7a7..3946666848b 100644 --- a/src/utils/proxy-server.ts +++ b/src/utils/proxy-server.ts @@ -65,6 +65,7 @@ export const startProxyServer = async ({ site, siteInfo, state, + watchIgnore, deployEnvironment, }: { accountId: string | undefined @@ -90,6 +91,7 @@ export const startProxyServer = async ({ projectDir: string repositoryRoot?: string state: LocalState + watchIgnore: string[] functionsRegistry?: FunctionsRegistry deployEnvironment: { key: string; value: string; isSecret: boolean; scopes: string[] }[] }) => { @@ -116,6 +118,7 @@ export const startProxyServer = async ({ accountId, repositoryRoot, api, + watchIgnore, deployEnvironment, }) if (!url) { diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 9bbd0b84289..23450828303 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -947,6 +947,7 @@ export const startProxy = async function ({ settings, siteInfo, state, + watchIgnore, deployEnvironment, }: { command: BaseCommand @@ -955,6 +956,7 @@ export const startProxy = async function ({ disableEdgeFunctions: boolean getUpdatedConfig: () => Promise aiGatewayContext?: AIGatewayContext | null + watchIgnore: string[] deployEnvironment: { key: string; value: string; isSecret: boolean; scopes: string[] }[] } & Record) { const secondaryServerPort = settings.https ? await getAvailablePort() : null @@ -988,6 +990,7 @@ export const startProxy = async function ({ repositoryRoot, siteInfo, state, + watchIgnore, deployEnvironment, }) } diff --git a/tests/unit/lib/edge-functions/watch-ignore.test.ts b/tests/unit/lib/edge-functions/watch-ignore.test.ts new file mode 100644 index 00000000000..f73db96d056 --- /dev/null +++ b/tests/unit/lib/edge-functions/watch-ignore.test.ts @@ -0,0 +1,241 @@ +import { statSync } from 'fs' +import { join } from 'path' + +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import type BaseCommand from '../../../../src/commands/base-command.js' +import { EdgeFunctionsRegistryImpl } from '../../../../src/lib/edge-functions/registry.js' +import type { NormalizedCachedConfigConfig } from '../../../../src/utils/command-helpers.js' + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync) } +}) + +vi.mock('@netlify/dev-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + watchDebounced: vi.fn().mockResolvedValue({ close: vi.fn(), add: vi.fn(), unwatch: vi.fn() }), + } +}) + +// Creates a partial registry via Object.create so the constructor is bypassed, +// then populates the private fields needed by setupWatcherForDirectory. +const makeRegistry = (fields: { projectDir: string; servePath: string; publishDir: string; watchIgnore: string[] }) => { + const registry = Object.create(EdgeFunctionsRegistryImpl.prototype) as EdgeFunctionsRegistryImpl + Object.assign(registry, { + ...fields, + directoryWatchers: new Map(), + checkForAddedOrDeletedFunctions: vi.fn(), + handleFileChange: vi.fn(), + }) + return registry +} + +const captureIgnored = async (registry: EdgeFunctionsRegistryImpl): Promise<(string | RegExp)[]> => { + const { watchDebounced } = await import('@netlify/dev-utils') + vi.mocked(watchDebounced).mockClear() + await (registry as unknown as { setupWatcherForDirectory: () => Promise }).setupWatcherForDirectory() + const [, options] = vi.mocked(watchDebounced).mock.calls[0] + return (options as { ignored: (string | RegExp)[] }).ignored +} + +describe('toIgnoredRegex', () => { + // The regex is defined inline in setupWatcherForDirectory. We test it by + // capturing the ignored array passed to watchDebounced. + + test('matches the directory path itself', async () => { + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: [], + }) + const ignored = await captureIgnored(registry) + const servePathRegex = ignored[0] as RegExp + expect(servePathRegex.test('/project/.netlify/edge-functions-serve')).toBe(true) + }) + + test('matches paths under the directory', async () => { + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: [], + }) + const ignored = await captureIgnored(registry) + const publishDirRegex = ignored[1] as RegExp + expect(publishDirRegex.test('/project/_site/posts/2024/index.html')).toBe(true) + }) + + test('does not match a sibling path that shares a prefix', async () => { + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: [], + }) + const ignored = await captureIgnored(registry) + const publishDirRegex = ignored[1] as RegExp + expect(publishDirRegex.test('/project/_site-backup/index.html')).toBe(false) + }) + + test('escapes special regex characters in the path', async () => { + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/my.build (v2)', + watchIgnore: [], + }) + const ignored = await captureIgnored(registry) + const publishDirRegex = ignored[1] as RegExp + expect(publishDirRegex.test('/project/my.build (v2)/index.html')).toBe(true) + expect(publishDirRegex.test('/projectXmyYbuild-v2/index.html')).toBe(false) + }) + + test('omits publishDir from ignored when it equals projectDir', async () => { + // When no publish dir is configured, settings.dist falls back to the working + // directory itself. Ignoring projectDir would block all file watching, so we + // skip adding it in that case. + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project', + watchIgnore: [], + }) + const ignored = await captureIgnored(registry) + // Only servePath regex + internalImportMapPath — no publishDir entry + expect(ignored).toHaveLength(2) + expect(ignored[0]).toBeInstanceOf(RegExp) + expect(typeof ignored[1]).toBe('string') + }) +}) + +describe('toIgnoredEntry (watchIgnore entries)', () => { + beforeEach(() => { + vi.mocked(statSync).mockReset() + }) + + test('returns a plain string for an existing file', async () => { + vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType) + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: ['/project/large-data.json'], + }) + const ignored = await captureIgnored(registry) + expect(ignored[2]).toBe('/project/large-data.json') + }) + + test('returns a regex for an existing directory', async () => { + vi.mocked(statSync).mockReturnValue({ isFile: () => false } as ReturnType) + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: ['/project/src/posts'], + }) + const ignored = await captureIgnored(registry) + expect(ignored[2]).toBeInstanceOf(RegExp) + expect((ignored[2] as RegExp).test('/project/src/posts/2024/my-post.md')).toBe(true) + }) + + test('returns a regex when the path does not exist yet', async () => { + vi.mocked(statSync).mockImplementation(() => { + throw new Error('ENOENT') + }) + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: ['/project/content'], + }) + const ignored = await captureIgnored(registry) + expect(ignored[2]).toBeInstanceOf(RegExp) + expect((ignored[2] as RegExp).test('/project/content/page.md')).toBe(true) + }) +}) + +describe('internalImportMapPath is kept as a plain string', () => { + test('import map path is passed as an exact-match string, not a regex', async () => { + const registry = makeRegistry({ + projectDir: '/project', + servePath: '/project/.netlify/edge-functions-serve', + publishDir: '/project/_site', + watchIgnore: [], + }) + const ignored = await captureIgnored(registry) + // servePath regex, publishDir regex, internalImportMapPath string + const importMapEntry = ignored[2] + expect(typeof importMapEntry).toBe('string') + expect(importMapEntry).toBe(join('/project', '.netlify', 'edge-functions-import-map.json')) + }) +}) + +describe('constructor path resolution', () => { + const makeOptions = (overrides: { publishDir?: string; watchIgnore?: string[] } = {}) => ({ + aiGatewayContext: null, + bundler: { find: vi.fn().mockResolvedValue([]) } as unknown as typeof import('@netlify/edge-bundler'), + command: { netlify: { config: { build: {} } }, workingDir: '/project' } as unknown as BaseCommand, + config: { edge_functions: [], functions: { '*': {} } } as unknown as NormalizedCachedConfigConfig, + configPath: '/project/netlify.toml', + debug: false, + env: {}, + featureFlags: {}, + getUpdatedConfig: vi.fn(), + projectDir: '/project', + publishDir: overrides.publishDir ?? '_site', + runIsolate: vi.fn() as unknown as Awaited>, + servePath: '/project/.netlify/edge-functions-serve', + watchIgnore: overrides.watchIgnore ?? [], + deployEnvironment: [], + }) + + beforeEach(() => { + vi.spyOn( + EdgeFunctionsRegistryImpl.prototype as unknown as { doInitialScan: () => Promise }, + 'doInitialScan', + ).mockResolvedValue(undefined) + vi.spyOn( + EdgeFunctionsRegistryImpl.prototype as unknown as { setupWatchers: () => Promise }, + 'setupWatchers', + ).mockResolvedValue(undefined) + }) + + type RegistryPrivateState = { publishDir: string; watchIgnore: string[] } + + test('resolves a relative publishDir against projectDir', () => { + const registry = new EdgeFunctionsRegistryImpl(makeOptions({ publishDir: '_site' })) + expect((registry as unknown as RegistryPrivateState).publishDir).toBe('/project/_site') + }) + + test('keeps an absolute publishDir unchanged', () => { + const registry = new EdgeFunctionsRegistryImpl(makeOptions({ publishDir: '/other/_site' })) + expect((registry as unknown as RegistryPrivateState).publishDir).toBe('/other/_site') + }) + + test('resolves relative watchIgnore paths against projectDir', () => { + const registry = new EdgeFunctionsRegistryImpl(makeOptions({ watchIgnore: ['src/posts', 'content'] })) + expect((registry as unknown as RegistryPrivateState).watchIgnore).toEqual([ + '/project/src/posts', + '/project/content', + ]) + }) + + test('keeps absolute watchIgnore paths unchanged', () => { + const registry = new EdgeFunctionsRegistryImpl( + makeOptions({ watchIgnore: ['/absolute/src/posts', '/other/content'] }), + ) + expect((registry as unknown as RegistryPrivateState).watchIgnore).toEqual(['/absolute/src/posts', '/other/content']) + }) + + test('handles a mix of relative and absolute watchIgnore paths', () => { + const registry = new EdgeFunctionsRegistryImpl(makeOptions({ watchIgnore: ['src/posts', '/absolute/content'] })) + expect((registry as unknown as RegistryPrivateState).watchIgnore).toEqual([ + '/project/src/posts', + '/absolute/content', + ]) + }) +})