From a2b1d1fe86f971a1d8bb9a252cc3dd8740c1344f Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sat, 31 Jan 2026 16:37:25 +0100 Subject: [PATCH 01/12] feat(workspace-packages): API watch and reload packages --- packages/api-server/src/watch.ts | 58 +++++++++++---- packages/api-server/src/workspacePackages.ts | 77 ++++++++++++++++++++ 2 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 packages/api-server/src/workspacePackages.ts diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index 444190bb26..b58378233c 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -1,4 +1,5 @@ -import path from 'path' +import fs from 'node:fs' +import path from 'node:path' // See https://github.com/webdiscus/ansis#troubleshooting // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -18,6 +19,7 @@ import { ensurePosixPath, getPaths, getDbDir } from '@cedarjs/project-config' import type { BuildAndRestartOptions } from './buildManager.js' import { BuildManager } from './buildManager.js' import { serverManager } from './serverManager.js' +import { workspacePackages } from './workspacePackages.js' const cedarPaths = getPaths() @@ -73,19 +75,38 @@ async function validateSdls() { * Initialize the file watcher for the API server * Watches for changes in the API source directory and rebuilds/restarts as * needed + * + * Also watches package sources so that changes to workspace packages used by + * the API trigger a rebuild/restart (HMR for API-side workspace packages). */ export async function startWatch() { const dbDir = await getDbDir(cedarPaths.api.prismaConfig) // NOTE: the file with a detected change comes through as a unix path, even on // windows. So we need to convert the cedarPaths + const packagesDir = path.join(cedarPaths.base, 'packages') + const packageIgnoredPaths: string[] = [] + + if (fs.existsSync(packagesDir)) { + packageIgnoredPaths.push( + path.join(packagesDir, '*/dist'), + path.join(packagesDir, '*/dist/**'), + path.join(packagesDir, '*/node_modules'), + ) + } + const ignoredApiPaths = [ // use this, because using cedarPaths.api.dist seems to not ignore on first // build 'api/dist', cedarPaths.api.types, dbDir, - ].map((path) => ensurePosixPath(path)) + ] + + const ignoredWatchPaths = [...ignoredApiPaths, ...packageIgnoredPaths].map( + (p) => ensurePosixPath(p), + ) + const ignoredExtensions = [ '.DS_Store', '.db', @@ -99,18 +120,23 @@ export async function startWatch() { '.log', ] - const watcher = chokidar.watch([cedarPaths.api.src], { - persistent: true, - ignoreInitial: true, - ignored: (file: string) => { - const shouldIgnore = - file.includes('node_modules') || - ignoredApiPaths.some((ignoredPath) => file.includes(ignoredPath)) || - ignoredExtensions.some((ext) => file.endsWith(ext)) - - return shouldIgnore + const watchPaths = [cedarPaths.api.src, ...(await workspacePackages())] + + const watcher = chokidar.watch( + watchPaths.map((p) => ensurePosixPath(p)), + { + persistent: true, + ignoreInitial: true, + ignored: (file: string) => { + const shouldIgnore = + file.includes('node_modules') || + ignoredWatchPaths.some((ignoredPath) => file.includes(ignoredPath)) || + ignoredExtensions.some((ext) => file.endsWith(ext)) + + return shouldIgnore + }, }, - }) + ) watcher.on('ready', async () => { // First time @@ -140,9 +166,9 @@ export async function startWatch() { } } - console.log( - ansis.dim(`[${eventName}] ${filePath.replace(cedarPaths.api.base, '')}`), - ) + // Normalize the displayed path so it's relative to the project base. + const displayPath = path.relative(cedarPaths.base, filePath) + console.log(ansis.dim(`[${eventName}] ${displayPath}`)) buildManager.cancelScheduledBuild() diff --git a/packages/api-server/src/workspacePackages.ts b/packages/api-server/src/workspacePackages.ts new file mode 100644 index 0000000000..013b91ba85 --- /dev/null +++ b/packages/api-server/src/workspacePackages.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { getPaths } from '@cedarjs/project-config' + +export async function workspacePackages() { + const cedarPaths = getPaths() + const packagesDir = path.join(cedarPaths.base, 'packages') + + const packages: string[] = [] + + try { + const rootPackageJsonPath = path.join(cedarPaths.base, 'package.json') + + const rootPackageJson = JSON.parse( + fs.readFileSync(rootPackageJsonPath, 'utf8'), + ) + const hasPackageJsonWorkspaces = + Array.isArray(rootPackageJson.workspaces) && + rootPackageJson.workspaces.some((w: string) => w.startsWith('packages/')) + + // Optimization to return early if no workspace packages are defined + if (!hasPackageJsonWorkspaces || !fs.existsSync(packagesDir)) { + return [] + } + + const globPattern = path.join(packagesDir, '*').replaceAll('\\', '/') + const packageDirs = await Array.fromAsync(fs.promises.glob(globPattern)) + + const apiPackageJsonPath = path.join(cedarPaths.api.base, 'package.json') + + // Look for 'workspace:*' dependencies in the API package.json + // No need to watch *all* workspace packages, only need to watch those that + // the api workspace actually depends on + const apiPackageJson = JSON.parse( + fs.readFileSync(apiPackageJsonPath, 'utf8'), + ) + const deps = { + ...(apiPackageJson.dependencies ?? {}), + ...(apiPackageJson.devDependencies ?? {}), + ...(apiPackageJson.peerDependencies ?? {}), + } + + const workspaceDepNames = new Set() + + for (const [name, version] of Object.entries(deps)) { + if (String(version).startsWith('workspace:')) { + workspaceDepNames.add(name) + } + } + + for (const packageDir of packageDirs) { + const packageJsonPath = path.join(packageDir, 'package.json') + + if (!fs.existsSync(packageJsonPath)) { + continue + } + + const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + + if (workspaceDepNames.has(pkgJson.name)) { + const srcDir = path.join(packageDir, 'src') + + if (fs.existsSync(srcDir)) { + packages.push(path.join(srcDir, '**', '*')) + } else { + packages.push(path.join(packageDir, '**', '*')) + } + } + } + } catch { + // If anything goes wrong while determining workspace packages, ignore them + // all + } + + return packages +} From 30d2b90d9f713b983a5c2f518d8959390fc0ad7c Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sat, 31 Jan 2026 17:27:47 +0100 Subject: [PATCH 02/12] initial unit test --- .../src/__tests__/workspacePackages.test.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 packages/api-server/src/__tests__/workspacePackages.test.ts diff --git a/packages/api-server/src/__tests__/workspacePackages.test.ts b/packages/api-server/src/__tests__/workspacePackages.test.ts new file mode 100644 index 0000000000..1ab0aab57a --- /dev/null +++ b/packages/api-server/src/__tests__/workspacePackages.test.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import chokidar from 'chokidar' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { ensurePosixPath } from '@cedarjs/project-config' + +import { workspacePackages } from '../workspacePackages.js' + +describe('workspacePackages integration with chokidar', () => { + let tmpDir: string + const originalRwjsCwd = process.env.RWJS_CWD + + beforeAll(async () => { + // Create an isolated temp project directory + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'cedar-workspace-packages-test-'), + ) + + // Ensure we are recognized as a Cedar project + await fs.promises.writeFile(path.join(tmpDir, 'cedar.toml'), '# cedar test') + + // Root package.json with workspace globs + const rootPackageJson = { + name: 'workspace-test', + private: true, + workspaces: ['packages/*'], + } + await fs.promises.writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify(rootPackageJson, null, 2), + { encoding: 'utf8' }, + ) + + // Create a workspace package with a `src` directory that will be watched + const fooSrcDir = path.join(tmpDir, 'packages', 'foo', 'src') + await fs.promises.mkdir(fooSrcDir, { recursive: true }) + const fooIndexPath = path.join(fooSrcDir, 'index.ts') + await fs.promises.writeFile(fooIndexPath, 'export const foo = 1') + + // Create the `package.json` for the workspace package so + // workspacePackages() will detect it as a workspace dependency from the + // `api` package. + await fs.promises.writeFile( + path.join(tmpDir, 'packages', 'foo', 'package.json'), + JSON.stringify({ name: 'foo', version: '1.0.0' }, null, 2), + ) + + // Create an `api` package that depends on the workspace package via + // `workspace:*` + const apiDir = path.join(tmpDir, 'api') + await fs.promises.mkdir(apiDir, { recursive: true }) + const apiPackageJson = { + name: 'api', + version: '1.0.0', + dependencies: { + foo: 'workspace:*', + }, + } + await fs.promises.writeFile( + path.join(apiDir, 'package.json'), + JSON.stringify(apiPackageJson, null, 2), + { encoding: 'utf8' }, + ) + + // Tell project-config to treat our temp dir as the project root + process.env.RWJS_CWD = tmpDir + }) + + afterAll(async () => { + // Restore environment and cleanup + if (originalRwjsCwd === undefined) { + delete process.env.RWJS_CWD + } else { + process.env.RWJS_CWD = originalRwjsCwd + } + + try { + await fs.promises.rm(tmpDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + + it('returns patterns that works with chokidar', async () => { + // Get the patterns workspacePackages provides + const patterns = await workspacePackages() + + // If no patterns were returned, collect and assert helpful debug info so + // failures on CI (particularly Windows runners) give actionable output. + if (patterns.length === 0) { + const packagesDir = path.join(tmpDir, 'packages') + const packagesDirExists = fs.existsSync(packagesDir) + expect(packagesDirExists).toBe(true) + + const packageJsonPath = path.join(packagesDir, 'foo', 'package.json') + const packageJsonExists = fs.existsSync(packageJsonPath) + expect(packageJsonExists).toBe(true) + + const rootPkg = JSON.parse( + await fs.promises.readFile(path.join(tmpDir, 'package.json'), 'utf8'), + ) + expect(Array.isArray(rootPkg.workspaces)).toBe(true) + expect( + rootPkg.workspaces.some((w: string) => w.startsWith('packages/')), + ).toBe(true) + + const apiPkg = JSON.parse( + await fs.promises.readFile( + path.join(tmpDir, 'api', 'package.json'), + 'utf8', + ), + ) + expect(apiPkg.dependencies?.foo).toBe('workspace:*') + + const globPattern = path.join(packagesDir, '*').replaceAll('\\', '/') + + let packageDirs: string[] = [] + try { + // Mirror the logic in `workspacePackages()` which uses fs.promises.glob + // and Array.fromAsync to enumerate matching package directories. + + packageDirs = await Array.fromAsync(fs.promises.glob(globPattern)) + } catch (e: any) { + console.log('glob error', e?.message ?? e) + } + + console.log( + JSON.stringify( + { patterns, packagesDir, globPattern, packageDirs, rootPkg, apiPkg }, + null, + 2, + ), + ) + + expect(packageDirs.length).toBeGreaterThan(0) + } + + // Mimic how `startWatch()` feeds patterns to chokidar by normalizing paths + const watchPatterns: string[] = patterns.map((p: string) => + ensurePosixPath(p), + ) + + // Ensure we've normalized separators (no backslashes) so the test failure + // is explicit if normalization doesn't happen. + for (const p of watchPatterns) { + expect(p.includes('\\')).toBe(false) + } + + // Diagnostic logging: show raw and normalized patterns so CI logs are + // actionable if globbing doesn't behave as expected on a runner. + console.log('workspace patterns (raw):', JSON.stringify(patterns, null, 2)) + console.log( + 'workspace patterns (normalized):', + JSON.stringify(watchPatterns, null, 2), + ) + + // Diagnostic: expand the packages/* glob (like workspacePackages does) and + // log the matches. This helps surface platform-specific globbing issues, + // especially on Windows runners. + try { + const packagesDirForDebug = path.join(tmpDir, 'packages') + const globPatternForDebug = path + .join(packagesDirForDebug, '*') + .replaceAll('\\', '/') + + const packageDirsForDebug = await Array.fromAsync( + fs.promises.glob(globPatternForDebug), + ) + console.log('packages glob pattern:', globPatternForDebug) + console.log( + 'packages glob matches:', + JSON.stringify(packageDirsForDebug, null, 2), + ) + } catch (e: any) { + console.log('packages glob error:', e?.message ?? e) + } + + const watcher = chokidar.watch(watchPatterns, { + persistent: true, + ignoreInitial: true, + }) + + // Surface watcher errors immediately to test logs + watcher.on('error', (error) => { + console.error('chokidar watcher error:', error) + }) + + try { + // Wait until the watcher is ready + await new Promise((resolve) => { + watcher.on('ready', () => { + try { + console.debug( + 'chokidar ready; watched directories:', + JSON.stringify(watcher.getWatched(), null, 2), + ) + } catch (e) { + console.debug('chokidar ready; could not serialize watched dirs', e) + } + resolve() + }) + }) + + // Prepare a promise that resolves when chokidar reports the change + const eventPromise = new Promise<{ eventName: string; filePath: string }>( + (resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for chokidar event')) + }, 10_000) + + const onAll = (eventName: string, filePath: string) => { + try { + console.debug('chokidar event:', eventName, filePath) + } catch (e) { + console.debug('chokidar event logging failed', e) + } + + // Normalize the reported path so this works across OSes + const normalized = String(filePath).replace(/\\/g, '/') + + if (normalized.endsWith('/packages/foo/src/index.ts')) { + clearTimeout(timeout) + watcher.off('all', onAll) + resolve({ eventName, filePath }) + } + } + + watcher.on('all', onAll) + }, + ) + + // Trigger a change in the watched file + const targetFile = path.join(tmpDir, 'packages', 'foo', 'src', 'index.ts') + try { + const beforeStat = await fs.promises.stat(targetFile) + console.debug('targetFile mtime before append:', beforeStat.mtimeMs) + } catch (e) { + console.debug('stat before append failed:', e) + } + + await fs.promises.appendFile(targetFile, '\n// update\n', { + encoding: 'utf8', + }) + + try { + const afterStat = await fs.promises.stat(targetFile) + console.debug('targetFile mtime after append:', afterStat.mtimeMs) + } catch (e) { + console.debug('stat after append failed:', e) + } + + const { eventName } = await eventPromise + + // chokidar could report either `add` (in some races) or `change` for the edit + expect(['add', 'change']).toContain(eventName) + } finally { + // Always close the watcher + await watcher.close() + } + }, 20_000) +}) From 7c83d7dd40a7571c3f73ed9c47e3a91db9e555ee Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sat, 31 Jan 2026 18:49:10 +0100 Subject: [PATCH 03/12] unit test importStatementPath --- .../api-server/src/__tests__/workspacePackages.test.ts | 5 ++--- packages/api-server/src/watch.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/api-server/src/__tests__/workspacePackages.test.ts b/packages/api-server/src/__tests__/workspacePackages.test.ts index 1ab0aab57a..e816290229 100644 --- a/packages/api-server/src/__tests__/workspacePackages.test.ts +++ b/packages/api-server/src/__tests__/workspacePackages.test.ts @@ -5,7 +5,7 @@ import path from 'node:path' import chokidar from 'chokidar' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { ensurePosixPath } from '@cedarjs/project-config' +import { importStatementPath } from '@cedarjs/project-config' import { workspacePackages } from '../workspacePackages.js' @@ -31,7 +31,6 @@ describe('workspacePackages integration with chokidar', () => { await fs.promises.writeFile( path.join(tmpDir, 'package.json'), JSON.stringify(rootPackageJson, null, 2), - { encoding: 'utf8' }, ) // Create a workspace package with a `src` directory that will be watched @@ -140,7 +139,7 @@ describe('workspacePackages integration with chokidar', () => { // Mimic how `startWatch()` feeds patterns to chokidar by normalizing paths const watchPatterns: string[] = patterns.map((p: string) => - ensurePosixPath(p), + importStatementPath(p), ) // Ensure we've normalized separators (no backslashes) so the test failure diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index b58378233c..a74155dd54 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -14,7 +14,11 @@ import { rebuildApi, } from '@cedarjs/internal/dist/build/api' import { loadAndValidateSdls } from '@cedarjs/internal/dist/validateSchema' -import { ensurePosixPath, getPaths, getDbDir } from '@cedarjs/project-config' +import { + importStatementPath, + getPaths, + getDbDir, +} from '@cedarjs/project-config' import type { BuildAndRestartOptions } from './buildManager.js' import { BuildManager } from './buildManager.js' @@ -104,7 +108,7 @@ export async function startWatch() { ] const ignoredWatchPaths = [...ignoredApiPaths, ...packageIgnoredPaths].map( - (p) => ensurePosixPath(p), + (p) => importStatementPath(p), ) const ignoredExtensions = [ @@ -123,7 +127,7 @@ export async function startWatch() { const watchPaths = [cedarPaths.api.src, ...(await workspacePackages())] const watcher = chokidar.watch( - watchPaths.map((p) => ensurePosixPath(p)), + watchPaths.map((p) => importStatementPath(p)), { persistent: true, ignoreInitial: true, From b1380576901ceb3571ad26c107b159dcd8e87f67 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 10:42:21 +0100 Subject: [PATCH 04/12] Extract more logic into watchPaths.ts --- ...acePackages.test.ts => watchPaths.test.ts} | 17 +- packages/api-server/src/watch.ts | 71 +------- packages/api-server/src/watchPaths.ts | 161 ++++++++++++++++++ packages/api-server/src/workspacePackages.ts | 77 --------- 4 files changed, 174 insertions(+), 152 deletions(-) rename packages/api-server/src/__tests__/{workspacePackages.test.ts => watchPaths.test.ts} (94%) create mode 100644 packages/api-server/src/watchPaths.ts delete mode 100644 packages/api-server/src/workspacePackages.ts diff --git a/packages/api-server/src/__tests__/workspacePackages.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts similarity index 94% rename from packages/api-server/src/__tests__/workspacePackages.test.ts rename to packages/api-server/src/__tests__/watchPaths.test.ts index e816290229..13dbc4299e 100644 --- a/packages/api-server/src/__tests__/workspacePackages.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -5,9 +5,7 @@ import path from 'node:path' import chokidar from 'chokidar' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { importStatementPath } from '@cedarjs/project-config' - -import { workspacePackages } from '../workspacePackages.js' +import { pathsToWatch } from '../watchPaths.js' describe('workspacePackages integration with chokidar', () => { let tmpDir: string @@ -85,7 +83,7 @@ describe('workspacePackages integration with chokidar', () => { it('returns patterns that works with chokidar', async () => { // Get the patterns workspacePackages provides - const patterns = await workspacePackages() + const patterns = await pathsToWatch() // If no patterns were returned, collect and assert helpful debug info so // failures on CI (particularly Windows runners) give actionable output. @@ -137,14 +135,9 @@ describe('workspacePackages integration with chokidar', () => { expect(packageDirs.length).toBeGreaterThan(0) } - // Mimic how `startWatch()` feeds patterns to chokidar by normalizing paths - const watchPatterns: string[] = patterns.map((p: string) => - importStatementPath(p), - ) - // Ensure we've normalized separators (no backslashes) so the test failure // is explicit if normalization doesn't happen. - for (const p of watchPatterns) { + for (const p of patterns) { expect(p.includes('\\')).toBe(false) } @@ -153,7 +146,7 @@ describe('workspacePackages integration with chokidar', () => { console.log('workspace patterns (raw):', JSON.stringify(patterns, null, 2)) console.log( 'workspace patterns (normalized):', - JSON.stringify(watchPatterns, null, 2), + JSON.stringify(patterns, null, 2), ) // Diagnostic: expand the packages/* glob (like workspacePackages does) and @@ -177,7 +170,7 @@ describe('workspacePackages integration with chokidar', () => { console.log('packages glob error:', e?.message ?? e) } - const watcher = chokidar.watch(watchPatterns, { + const watcher = chokidar.watch(patterns, { persistent: true, ignoreInitial: true, }) diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index a74155dd54..8c6d287f6f 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs' import path from 'node:path' // See https://github.com/webdiscus/ansis#troubleshooting @@ -14,16 +13,12 @@ import { rebuildApi, } from '@cedarjs/internal/dist/build/api' import { loadAndValidateSdls } from '@cedarjs/internal/dist/validateSchema' -import { - importStatementPath, - getPaths, - getDbDir, -} from '@cedarjs/project-config' +import { getPaths } from '@cedarjs/project-config' import type { BuildAndRestartOptions } from './buildManager.js' import { BuildManager } from './buildManager.js' import { serverManager } from './serverManager.js' -import { workspacePackages } from './workspacePackages.js' +import { getIgnoreFunction, pathsToWatch } from './watchPaths.js' const cedarPaths = getPaths() @@ -84,63 +79,13 @@ async function validateSdls() { * the API trigger a rebuild/restart (HMR for API-side workspace packages). */ export async function startWatch() { - const dbDir = await getDbDir(cedarPaths.api.prismaConfig) - - // NOTE: the file with a detected change comes through as a unix path, even on - // windows. So we need to convert the cedarPaths - const packagesDir = path.join(cedarPaths.base, 'packages') - const packageIgnoredPaths: string[] = [] - - if (fs.existsSync(packagesDir)) { - packageIgnoredPaths.push( - path.join(packagesDir, '*/dist'), - path.join(packagesDir, '*/dist/**'), - path.join(packagesDir, '*/node_modules'), - ) - } + const patterns = await pathsToWatch() - const ignoredApiPaths = [ - // use this, because using cedarPaths.api.dist seems to not ignore on first - // build - 'api/dist', - cedarPaths.api.types, - dbDir, - ] - - const ignoredWatchPaths = [...ignoredApiPaths, ...packageIgnoredPaths].map( - (p) => importStatementPath(p), - ) - - const ignoredExtensions = [ - '.DS_Store', - '.db', - '.sqlite', - '-journal', - '.test.js', - '.test.ts', - '.scenarios.ts', - '.scenarios.js', - '.d.ts', - '.log', - ] - - const watchPaths = [cedarPaths.api.src, ...(await workspacePackages())] - - const watcher = chokidar.watch( - watchPaths.map((p) => importStatementPath(p)), - { - persistent: true, - ignoreInitial: true, - ignored: (file: string) => { - const shouldIgnore = - file.includes('node_modules') || - ignoredWatchPaths.some((ignoredPath) => file.includes(ignoredPath)) || - ignoredExtensions.some((ext) => file.endsWith(ext)) - - return shouldIgnore - }, - }, - ) + const watcher = chokidar.watch(patterns, { + persistent: true, + ignoreInitial: true, + ignored: await getIgnoreFunction(), + }) watcher.on('ready', async () => { // First time diff --git a/packages/api-server/src/watchPaths.ts b/packages/api-server/src/watchPaths.ts new file mode 100644 index 0000000000..751c5d2ff3 --- /dev/null +++ b/packages/api-server/src/watchPaths.ts @@ -0,0 +1,161 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + getDbDir, + getPaths, + importStatementPath, +} from '@cedarjs/project-config' + +async function workspacePackagesPaths() { + const cedarPaths = getPaths() + const packagesDir = path.join(cedarPaths.base, 'packages') + + const packages: string[] = [] + + try { + const rootPackageJsonPath = path.join(cedarPaths.base, 'package.json') + + const rootPackageJson = JSON.parse( + fs.readFileSync(rootPackageJsonPath, 'utf8'), + ) + const hasPackageJsonWorkspaces = + Array.isArray(rootPackageJson.workspaces) && + rootPackageJson.workspaces.some((w: string) => w.startsWith('packages/')) + + // Optimization to return early if no workspace packages are defined + if (!hasPackageJsonWorkspaces || !fs.existsSync(packagesDir)) { + return [] + } + + const globPattern = path.join(packagesDir, '*').replaceAll('\\', '/') + const packageDirs = await Array.fromAsync(fs.promises.glob(globPattern)) + + const apiPackageJsonPath = path.join(cedarPaths.api.base, 'package.json') + + // Look for 'workspace:*' dependencies in the API package.json + // No need to watch *all* workspace packages, only need to watch those that + // the api workspace actually depends on + const apiPackageJson = JSON.parse( + fs.readFileSync(apiPackageJsonPath, 'utf8'), + ) + const deps = { + ...(apiPackageJson.dependencies ?? {}), + ...(apiPackageJson.devDependencies ?? {}), + ...(apiPackageJson.peerDependencies ?? {}), + } + + const workspaceDepNames = new Set() + + for (const [name, version] of Object.entries(deps)) { + if (String(version).startsWith('workspace:')) { + workspaceDepNames.add(name) + } + } + + for (const packageDir of packageDirs) { + const packageJsonPath = path.join(packageDir, 'package.json') + + if (!fs.existsSync(packageJsonPath)) { + continue + } + + const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + + if (workspaceDepNames.has(pkgJson.name)) { + const srcDir = path.join(packageDir, 'src') + + if (fs.existsSync(srcDir)) { + packages.push(path.join(srcDir, '**', '*')) + } else { + packages.push(path.join(packageDir, '**', '*')) + } + } + } + } catch { + // If anything goes wrong while determining workspace packages, ignore them + // all + } + + return packages +} + +function workspacePackagesIgnorePaths() { + const cedarPaths = getPaths() + + const packagesDir = path.join(cedarPaths.base, 'packages') + const packageIgnoredPaths: string[] = [] + + if (fs.existsSync(packagesDir)) { + packageIgnoredPaths.push( + path.join(packagesDir, '*/dist'), + path.join(packagesDir, '*/dist/**'), + path.join(packagesDir, '*/node_modules'), + ) + } + + return packageIgnoredPaths +} + +async function apiIgnorePaths() { + const cedarPaths = getPaths() + + const dbDir = await getDbDir(cedarPaths.api.prismaConfig) + + const ignoredApiPaths = [ + // TODO: Is this still true? + // use this, because using cedarPaths.api.dist seems to not ignore on first + // build + 'api/dist', + cedarPaths.api.types, + dbDir, + ] + + return ignoredApiPaths +} + +async function ignorePaths() { + // The file with a detected change comes through as a unix path, even on + // windows. So we need to convert all paths to unix-style paths to ensure + // matches. Plus, chokidar needs unix-style `/` path separators for globs even + // on Windows, which is exactly what `importStatementPath()` converts paths to + const apiIgnore = await apiIgnorePaths() + const packagesIgnore = workspacePackagesIgnorePaths() + return [...apiIgnore, ...packagesIgnore].map((p) => importStatementPath(p)) +} + +export async function getIgnoreFunction() { + const ignoredWatchPaths = await ignorePaths() + + const ignoredExtensions = [ + '.DS_Store', + '.db', + '.sqlite', + '-journal', + '.test.js', + '.test.ts', + '.scenarios.ts', + '.scenarios.js', + '.d.ts', + '.log', + ] + + return (file: string) => { + const shouldIgnore = + file.includes('node_modules') || + ignoredWatchPaths.some((ignoredPath) => file.includes(ignoredPath)) || + ignoredExtensions.some((ext) => file.endsWith(ext)) + + return shouldIgnore + } +} + +export async function pathsToWatch() { + const cedarPaths = getPaths() + const watchPaths = [cedarPaths.api.src, ...(await workspacePackagesPaths())] + + // For glob paths, which `workspacePackages()` above might return, chokidar + // needs unix-style `/` path separators also on Windows, which is exactly what + // `importStatementPath()` provides. + return watchPaths.map((p) => importStatementPath(p)) +} diff --git a/packages/api-server/src/workspacePackages.ts b/packages/api-server/src/workspacePackages.ts deleted file mode 100644 index 013b91ba85..0000000000 --- a/packages/api-server/src/workspacePackages.ts +++ /dev/null @@ -1,77 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { getPaths } from '@cedarjs/project-config' - -export async function workspacePackages() { - const cedarPaths = getPaths() - const packagesDir = path.join(cedarPaths.base, 'packages') - - const packages: string[] = [] - - try { - const rootPackageJsonPath = path.join(cedarPaths.base, 'package.json') - - const rootPackageJson = JSON.parse( - fs.readFileSync(rootPackageJsonPath, 'utf8'), - ) - const hasPackageJsonWorkspaces = - Array.isArray(rootPackageJson.workspaces) && - rootPackageJson.workspaces.some((w: string) => w.startsWith('packages/')) - - // Optimization to return early if no workspace packages are defined - if (!hasPackageJsonWorkspaces || !fs.existsSync(packagesDir)) { - return [] - } - - const globPattern = path.join(packagesDir, '*').replaceAll('\\', '/') - const packageDirs = await Array.fromAsync(fs.promises.glob(globPattern)) - - const apiPackageJsonPath = path.join(cedarPaths.api.base, 'package.json') - - // Look for 'workspace:*' dependencies in the API package.json - // No need to watch *all* workspace packages, only need to watch those that - // the api workspace actually depends on - const apiPackageJson = JSON.parse( - fs.readFileSync(apiPackageJsonPath, 'utf8'), - ) - const deps = { - ...(apiPackageJson.dependencies ?? {}), - ...(apiPackageJson.devDependencies ?? {}), - ...(apiPackageJson.peerDependencies ?? {}), - } - - const workspaceDepNames = new Set() - - for (const [name, version] of Object.entries(deps)) { - if (String(version).startsWith('workspace:')) { - workspaceDepNames.add(name) - } - } - - for (const packageDir of packageDirs) { - const packageJsonPath = path.join(packageDir, 'package.json') - - if (!fs.existsSync(packageJsonPath)) { - continue - } - - const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) - - if (workspaceDepNames.has(pkgJson.name)) { - const srcDir = path.join(packageDir, 'src') - - if (fs.existsSync(srcDir)) { - packages.push(path.join(srcDir, '**', '*')) - } else { - packages.push(path.join(packageDir, '**', '*')) - } - } - } - } catch { - // If anything goes wrong while determining workspace packages, ignore them - // all - } - - return packages -} From c9745b37214fb9d0c0072cb26f5190466cc08c3f Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 14:39:30 +0100 Subject: [PATCH 05/12] test workaround --- .../api-server/src/__tests__/watchPaths.test.ts | 13 +++++++------ packages/api-server/src/watch.ts | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index 13dbc4299e..2d62c4081a 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -143,11 +143,7 @@ describe('workspacePackages integration with chokidar', () => { // Diagnostic logging: show raw and normalized patterns so CI logs are // actionable if globbing doesn't behave as expected on a runner. - console.log('workspace patterns (raw):', JSON.stringify(patterns, null, 2)) - console.log( - 'workspace patterns (normalized):', - JSON.stringify(patterns, null, 2), - ) + console.log('workspace patterns', JSON.stringify(patterns, null, 2)) // Diagnostic: expand the packages/* glob (like workspacePackages does) and // log the matches. This helps surface platform-specific globbing issues, @@ -192,7 +188,12 @@ describe('workspacePackages integration with chokidar', () => { } catch (e) { console.debug('chokidar ready; could not serialize watched dirs', e) } - resolve() + + setTimeout(() => { + console.log('Resolving onReady promise') + // This might get called twice. For this test that doesn't matter + resolve() + }, 1000) }) }) diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index 8c6d287f6f..7f6e1741a6 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -87,6 +87,8 @@ export async function startWatch() { ignored: await getIgnoreFunction(), }) + // This can fire multiple times + // https://github.com/paulmillr/chokidar/issues/338 watcher.on('ready', async () => { // First time await buildManager.run({ clean: true, rebuild: false }) From 756ac90df33acb0467065fda5393d223d8274927 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 15:14:24 +0100 Subject: [PATCH 06/12] no globs --- packages/api-server/src/watchPaths.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api-server/src/watchPaths.ts b/packages/api-server/src/watchPaths.ts index 751c5d2ff3..78c5bc1113 100644 --- a/packages/api-server/src/watchPaths.ts +++ b/packages/api-server/src/watchPaths.ts @@ -66,9 +66,9 @@ async function workspacePackagesPaths() { const srcDir = path.join(packageDir, 'src') if (fs.existsSync(srcDir)) { - packages.push(path.join(srcDir, '**', '*')) + packages.push(srcDir) } else { - packages.push(path.join(packageDir, '**', '*')) + packages.push(packageDir) } } } From 73d1519120b5bbbedf33b81efe5322c947657e70 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 15:14:48 +0100 Subject: [PATCH 07/12] remove test workaround --- packages/api-server/src/__tests__/watchPaths.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index 2d62c4081a..8e8ca125fc 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -189,11 +189,7 @@ describe('workspacePackages integration with chokidar', () => { console.debug('chokidar ready; could not serialize watched dirs', e) } - setTimeout(() => { - console.log('Resolving onReady promise') - // This might get called twice. For this test that doesn't matter - resolve() - }, 1000) + resolve() }) }) From a004584db7527dc963ec66502cd60e459e68c9b3 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 15:49:44 +0100 Subject: [PATCH 08/12] create api/src/index.ts for chokidar to detect --- packages/api-server/src/__tests__/watchPaths.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index 8e8ca125fc..d0097ec8f8 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -24,7 +24,7 @@ describe('workspacePackages integration with chokidar', () => { const rootPackageJson = { name: 'workspace-test', private: true, - workspaces: ['packages/*'], + workspaces: ['api', 'packages/*'], } await fs.promises.writeFile( path.join(tmpDir, 'package.json'), @@ -62,6 +62,15 @@ describe('workspacePackages integration with chokidar', () => { { encoding: 'utf8' }, ) + // Create an `api/src` directory so chokidar will watch an existing path. + const apiSrcDir = path.join(apiDir, 'src') + await fs.promises.mkdir(apiSrcDir, { recursive: true }) + await fs.promises.writeFile( + path.join(apiSrcDir, 'index.ts'), + "export const api = 'api'", + { encoding: 'utf8' }, + ) + // Tell project-config to treat our temp dir as the project root process.env.RWJS_CWD = tmpDir }) From 93959f344cfbab4136a002936f1d5ec484840a2b Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 17:27:28 +0100 Subject: [PATCH 09/12] simplify ignore watch paths --- .../src/__tests__/watchPaths.test.ts | 6 +----- packages/api-server/src/watch.ts | 18 ++++++++++-------- packages/api-server/src/watchPaths.ts | 6 +----- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index d0097ec8f8..9146178c39 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -59,7 +59,6 @@ describe('workspacePackages integration with chokidar', () => { await fs.promises.writeFile( path.join(apiDir, 'package.json'), JSON.stringify(apiPackageJson, null, 2), - { encoding: 'utf8' }, ) // Create an `api/src` directory so chokidar will watch an existing path. @@ -68,7 +67,6 @@ describe('workspacePackages integration with chokidar', () => { await fs.promises.writeFile( path.join(apiSrcDir, 'index.ts'), "export const api = 'api'", - { encoding: 'utf8' }, ) // Tell project-config to treat our temp dir as the project root @@ -239,9 +237,7 @@ describe('workspacePackages integration with chokidar', () => { console.debug('stat before append failed:', e) } - await fs.promises.appendFile(targetFile, '\n// update\n', { - encoding: 'utf8', - }) + await fs.promises.appendFile(targetFile, '\n// update\n') try { const afterStat = await fs.promises.stat(targetFile) diff --git a/packages/api-server/src/watch.ts b/packages/api-server/src/watch.ts index 7f6e1741a6..b75a032b53 100644 --- a/packages/api-server/src/watch.ts +++ b/packages/api-server/src/watch.ts @@ -88,6 +88,7 @@ export async function startWatch() { }) // This can fire multiple times + // https://github.com/paulmillr/chokidar/issues/286 // https://github.com/paulmillr/chokidar/issues/338 watcher.on('ready', async () => { // First time @@ -96,18 +97,19 @@ export async function startWatch() { }) watcher.on('all', async (eventName, filePath) => { - // On sufficiently large projects (500+ files, or >= 2000 ms build times) on older machines, - // esbuild writing to the api directory makes chokidar emit an `addDir` event. - // This starts an infinite loop where the api starts building itself as soon as it's finished. - // This could probably be fixed with some sort of build caching + // On sufficiently large projects (500+ files, or >= 2000 ms build times) on + // older machines, esbuild writing to the api directory makes chokidar emit + // an `addDir` event. This starts an infinite loop where the api starts + // building itself as soon as it's finished. This could probably be fixed + // with some sort of build caching if (eventName === 'addDir' && filePath === cedarPaths.api.base) { return } if (eventName) { if (filePath.includes('.sdl')) { - // We validate here, so that developers will see the error - // As they're running the dev server + // We validate here, so that developers will see the error as they're + // running the dev server const isValid = await validateSdls() // Exit early if not valid @@ -132,7 +134,7 @@ export async function startWatch() { }) } -// For ESM we'll wrap this in a check to only execute this function if -// the file is run as a script using +// For ESM we'll wrap this in a check to only execute this function if the file +// is run as a script using // `import.meta.url === `file://${process.argv[1]}`` startWatch() diff --git a/packages/api-server/src/watchPaths.ts b/packages/api-server/src/watchPaths.ts index 78c5bc1113..c395f64091 100644 --- a/packages/api-server/src/watchPaths.ts +++ b/packages/api-server/src/watchPaths.ts @@ -87,11 +87,7 @@ function workspacePackagesIgnorePaths() { const packageIgnoredPaths: string[] = [] if (fs.existsSync(packagesDir)) { - packageIgnoredPaths.push( - path.join(packagesDir, '*/dist'), - path.join(packagesDir, '*/dist/**'), - path.join(packagesDir, '*/node_modules'), - ) + packageIgnoredPaths.push(path.join(packagesDir, '*/dist')) } return packageIgnoredPaths From 68a90c59708bb04882a0169739db867d3594a58e Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 17:42:12 +0100 Subject: [PATCH 10/12] Test ignore paths --- .../src/__tests__/watchPaths.test.ts | 207 +++++++++++++++++- 1 file changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index 9146178c39..2c71f1feea 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -5,9 +5,9 @@ import path from 'node:path' import chokidar from 'chokidar' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { pathsToWatch } from '../watchPaths.js' +import { pathsToWatch, getIgnoreFunction } from '../watchPaths.js' -describe('workspacePackages integration with chokidar', () => { +describe('watchPaths', () => { let tmpDir: string const originalRwjsCwd = process.env.RWJS_CWD @@ -61,6 +61,17 @@ describe('workspacePackages integration with chokidar', () => { JSON.stringify(apiPackageJson, null, 2), ) + // Create a minimal Prisma config so `getIgnoreFunction()` can load it + // and determine the database directory without throwing. + await fs.promises.writeFile( + path.join(apiDir, 'prisma.config.cjs'), + "module.exports = { schema: 'schema.prisma' }", + { encoding: 'utf8' }, + ) + await fs.promises.writeFile(path.join(apiDir, 'schema.prisma'), '', { + encoding: 'utf8', + }) + // Create an `api/src` directory so chokidar will watch an existing path. const apiSrcDir = path.join(apiDir, 'src') await fs.promises.mkdir(apiSrcDir, { recursive: true }) @@ -255,4 +266,196 @@ describe('workspacePackages integration with chokidar', () => { await watcher.close() } }, 20_000) + + // Helper: wait until chokidar reports it's watching the package directory. + // This avoids races where 'ready' fires early. + async function waitForWatcherToWatchFoo( + watcher: chokidar.FSWatcher, + timeoutMs = 5_000, + ) { + const expected = path.join(tmpDir, 'packages', 'foo').replaceAll('\\', '/') + const deadline = Date.now() + timeoutMs + + while (Date.now() <= deadline) { + try { + const watchedKeys = Object.keys(watcher.getWatched()).map((k) => + k.replace(/\\/g, '/'), + ) + if ( + watchedKeys.some((k) => { + return k.endsWith(expected) || k.endsWith(expected + '/src') + }) + ) { + return + } + } catch { + // ignore transient serialization errors + } + + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + throw new Error( + `Timed out waiting for watcher to start watching ${expected}`, + ) + } + + it('ignores edits inside packages/foo/node_modules', async () => { + const patterns = await pathsToWatch() + const ignoreFn = await getIgnoreFunction() + + const watcher = chokidar.watch(patterns, { + persistent: true, + ignoreInitial: true, + ignored: ignoreFn, + }) + + watcher.on('error', (error) => { + console.error('chokidar watcher error:', error) + }) + + try { + // Wait until the watcher is ready + await new Promise((resolve) => { + watcher.on('ready', () => { + try { + console.debug( + 'chokidar ready; watched directories:', + JSON.stringify(watcher.getWatched(), null, 2), + ) + } catch (e) { + console.debug('chokidar ready; could not serialize watched dirs', e) + } + + resolve() + }) + }) + + await waitForWatcherToWatchFoo(watcher) + + const nodeFile = path.join( + tmpDir, + 'packages', + 'foo', + 'node_modules', + 'pkg', + 'index.ts', + ) + await fs.promises.mkdir(path.dirname(nodeFile), { recursive: true }) + await fs.promises.writeFile(nodeFile, 'export const x = 1', { + encoding: 'utf8', + }) + + const eventPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + watcher.off('all', onAll) + resolve() + }, 2_000) + + const onAll = (eventName: string, filePath: string) => { + try { + console.debug('chokidar event:', eventName, filePath) + } catch (e) { + console.debug('chokidar event logging failed', e) + } + + const normalized = String(filePath).replace(/\\/g, '/') + + if (normalized.includes('/packages/foo/node_modules/')) { + clearTimeout(timeout) + watcher.off('all', onAll) + reject( + new Error( + 'node_modules edit triggered watcher event: ' + normalized, + ), + ) + } + } + + watcher.on('all', onAll) + }) + + await fs.promises.appendFile(nodeFile, '\n// update\n', { + encoding: 'utf8', + }) + await eventPromise + } finally { + await watcher.close() + } + }, 10_000) + + it('ignores edits inside packages/foo/dist', async () => { + const patterns = await pathsToWatch() + const ignoreFn = await getIgnoreFunction() + + const watcher = chokidar.watch(patterns, { + persistent: true, + ignoreInitial: true, + ignored: ignoreFn, + }) + + watcher.on('error', (error) => { + console.error('chokidar watcher error:', error) + }) + + try { + // Wait until the watcher is ready + await new Promise((resolve) => { + watcher.on('ready', () => { + try { + console.debug( + 'chokidar ready; watched directories:', + JSON.stringify(watcher.getWatched(), null, 2), + ) + } catch (e) { + console.debug('chokidar ready; could not serialize watched dirs', e) + } + + resolve() + }) + }) + + await waitForWatcherToWatchFoo(watcher) + + const distFile = path.join(tmpDir, 'packages', 'foo', 'dist', 'index.ts') + await fs.promises.mkdir(path.dirname(distFile), { recursive: true }) + await fs.promises.writeFile(distFile, 'export const y = 1', { + encoding: 'utf8', + }) + + const eventPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + watcher.off('all', onAll) + resolve() + }, 2_000) + + const onAll = (eventName: string, filePath: string) => { + try { + console.debug('chokidar event:', eventName, filePath) + } catch (e) { + console.debug('chokidar event logging failed', e) + } + + const normalized = String(filePath).replace(/\\/g, '/') + + if (normalized.includes('/packages/foo/dist/')) { + clearTimeout(timeout) + watcher.off('all', onAll) + reject( + new Error('dist edit triggered watcher event: ' + normalized), + ) + } + } + + watcher.on('all', onAll) + }) + + await fs.promises.appendFile(distFile, '\n// update\n', { + encoding: 'utf8', + }) + await eventPromise + } finally { + await watcher.close() + } + }, 10_000) }) From 8ffa89971c2818672d29758c919660a359cabb26 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 19:24:41 +0100 Subject: [PATCH 11/12] watch dist --- .../src/__tests__/watchPaths.test.ts | 206 +++++++++--------- packages/api-server/src/watchPaths.ts | 10 +- 2 files changed, 110 insertions(+), 106 deletions(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index 2c71f1feea..7fac825b84 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -3,7 +3,9 @@ import os from 'node:os' import path from 'node:path' import chokidar from 'chokidar' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { vi, afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { importStatementPath } from '@cedarjs/project-config' import { pathsToWatch, getIgnoreFunction } from '../watchPaths.js' @@ -31,11 +33,16 @@ describe('watchPaths', () => { JSON.stringify(rootPackageJson, null, 2), ) - // Create a workspace package with a `src` directory that will be watched + // Create a workspace package with a `src` and a `dist` directory. Only the + // `dist` directory should be watched const fooSrcDir = path.join(tmpDir, 'packages', 'foo', 'src') + const fooDistDir = path.join(tmpDir, 'packages', 'foo', 'dist') await fs.promises.mkdir(fooSrcDir, { recursive: true }) - const fooIndexPath = path.join(fooSrcDir, 'index.ts') - await fs.promises.writeFile(fooIndexPath, 'export const foo = 1') + await fs.promises.mkdir(fooDistDir, { recursive: true }) + const fooIndexSrcPath = path.join(fooSrcDir, 'index.ts') + await fs.promises.writeFile(fooIndexSrcPath, 'export const foo = 1') + const fooIndexDistPath = path.join(fooDistDir, 'index.js') + await fs.promises.writeFile(fooIndexDistPath, 'export const foo = 1') // Create the `package.json` for the workspace package so // workspacePackages() will detect it as a workspace dependency from the @@ -66,11 +73,8 @@ describe('watchPaths', () => { await fs.promises.writeFile( path.join(apiDir, 'prisma.config.cjs'), "module.exports = { schema: 'schema.prisma' }", - { encoding: 'utf8' }, ) - await fs.promises.writeFile(path.join(apiDir, 'schema.prisma'), '', { - encoding: 'utf8', - }) + await fs.promises.writeFile(path.join(apiDir, 'schema.prisma'), '') // Create an `api/src` directory so chokidar will watch an existing path. const apiSrcDir = path.join(apiDir, 'src') @@ -100,7 +104,6 @@ describe('watchPaths', () => { }) it('returns patterns that works with chokidar', async () => { - // Get the patterns workspacePackages provides const patterns = await pathsToWatch() // If no patterns were returned, collect and assert helpful debug info so @@ -228,7 +231,7 @@ describe('watchPaths', () => { // Normalize the reported path so this works across OSes const normalized = String(filePath).replace(/\\/g, '/') - if (normalized.endsWith('/packages/foo/src/index.ts')) { + if (normalized.endsWith('/packages/foo/dist/index.js')) { clearTimeout(timeout) watcher.off('all', onAll) resolve({ eventName, filePath }) @@ -240,7 +243,13 @@ describe('watchPaths', () => { ) // Trigger a change in the watched file - const targetFile = path.join(tmpDir, 'packages', 'foo', 'src', 'index.ts') + const targetFile = path.join( + tmpDir, + 'packages', + 'foo', + 'dist', + 'index.js', + ) try { const beforeStat = await fs.promises.stat(targetFile) console.debug('targetFile mtime before append:', beforeStat.mtimeMs) @@ -265,40 +274,66 @@ describe('watchPaths', () => { // Always close the watcher await watcher.close() } - }, 20_000) - - // Helper: wait until chokidar reports it's watching the package directory. - // This avoids races where 'ready' fires early. - async function waitForWatcherToWatchFoo( - watcher: chokidar.FSWatcher, - timeoutMs = 5_000, - ) { - const expected = path.join(tmpDir, 'packages', 'foo').replaceAll('\\', '/') - const deadline = Date.now() + timeoutMs - - while (Date.now() <= deadline) { - try { - const watchedKeys = Object.keys(watcher.getWatched()).map((k) => - k.replace(/\\/g, '/'), - ) - if ( - watchedKeys.some((k) => { - return k.endsWith(expected) || k.endsWith(expected + '/src') + }, 10_000) + + it('chokidar triggers on new files added', async () => { + const patterns = await pathsToWatch() + + const watcher = chokidar.watch(patterns, { + persistent: true, + ignoreInitial: true, + }) + + // Surface watcher errors immediately to test logs + watcher.on('error', (error) => { + console.error('chokidar watcher error:', error) + // Always fail the test if an error occurs + expect(true).toBe(false) + }) + + let onAll = (_eventName: string, _filePath: string) => {} + + try { + // Wait until the watcher is ready + await new Promise((resolve) => watcher.on('ready', resolve)) + + // Prepare a promise that resolves when chokidar reports the change + const eventPromise = new Promise<{ eventName: string; filePath: string }>( + (resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for chokidar event')) + }, 10_000) + + onAll = vi.fn((eventName: string, filePath: string) => { + clearTimeout(timeout) + resolve({ eventName, filePath }) }) - ) { - return - } - } catch { - // ignore transient serialization errors - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } + watcher.on('all', onAll) + }, + ) - throw new Error( - `Timed out waiting for watcher to start watching ${expected}`, - ) - } + const distPath = path.join(tmpDir, 'packages', 'foo', 'dist') + + // Trigger a change in dist/ + await fs.promises.writeFile( + path.join(distPath, 'new-file.js'), + '\n// update\n', + ) + + const result = await eventPromise + + expect(result.eventName).toEqual('add') + expect(onAll).toHaveBeenCalledOnce() + expect(importStatementPath(result.filePath)).toMatch( + /packages\/foo\/dist\/new-file\.js$/, + ) + } finally { + // Always close the watcher + watcher.off('all', onAll) + await watcher.close() + } + }, 10_000) it('ignores edits inside packages/foo/node_modules', async () => { const patterns = await pathsToWatch() @@ -312,28 +347,15 @@ describe('watchPaths', () => { watcher.on('error', (error) => { console.error('chokidar watcher error:', error) + // Always fail the test if an error occurs + expect(true).toBe(false) }) try { // Wait until the watcher is ready - await new Promise((resolve) => { - watcher.on('ready', () => { - try { - console.debug( - 'chokidar ready; watched directories:', - JSON.stringify(watcher.getWatched(), null, 2), - ) - } catch (e) { - console.debug('chokidar ready; could not serialize watched dirs', e) - } - - resolve() - }) - }) + await new Promise((resolve) => watcher.on('ready', resolve)) - await waitForWatcherToWatchFoo(watcher) - - const nodeFile = path.join( + const nmFile = path.join( tmpDir, 'packages', 'foo', @@ -341,16 +363,14 @@ describe('watchPaths', () => { 'pkg', 'index.ts', ) - await fs.promises.mkdir(path.dirname(nodeFile), { recursive: true }) - await fs.promises.writeFile(nodeFile, 'export const x = 1', { - encoding: 'utf8', - }) + await fs.promises.mkdir(path.dirname(nmFile), { recursive: true }) + await fs.promises.writeFile(nmFile, 'export const x = 1') const eventPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { watcher.off('all', onAll) resolve() - }, 2_000) + }, 500) const onAll = (eventName: string, filePath: string) => { try { @@ -375,59 +395,53 @@ describe('watchPaths', () => { watcher.on('all', onAll) }) - await fs.promises.appendFile(nodeFile, '\n// update\n', { - encoding: 'utf8', - }) + await fs.promises.appendFile(nmFile, '\n// update\n') await eventPromise } finally { await watcher.close() } }, 10_000) - it('ignores edits inside packages/foo/dist', async () => { + it('ignores edits inside packages/foo/src', async () => { const patterns = await pathsToWatch() const ignoreFn = await getIgnoreFunction() + const wrappedIgnoreFn = vi.fn((path) => { + const returnValue = ignoreFn(path) + + if (returnValue) { + console.log(`Ignoring path: ${path}`) + } else { + console.log(`Not ignoring path: ${path}`) + } + + return returnValue + }) const watcher = chokidar.watch(patterns, { persistent: true, ignoreInitial: true, - ignored: ignoreFn, + ignored: wrappedIgnoreFn, }) watcher.on('error', (error) => { console.error('chokidar watcher error:', error) + // Always fail the test if an error occurs + expect(true).toBe(false) }) try { // Wait until the watcher is ready - await new Promise((resolve) => { - watcher.on('ready', () => { - try { - console.debug( - 'chokidar ready; watched directories:', - JSON.stringify(watcher.getWatched(), null, 2), - ) - } catch (e) { - console.debug('chokidar ready; could not serialize watched dirs', e) - } - - resolve() - }) - }) + await new Promise((resolve) => watcher.on('ready', resolve)) - await waitForWatcherToWatchFoo(watcher) - - const distFile = path.join(tmpDir, 'packages', 'foo', 'dist', 'index.ts') - await fs.promises.mkdir(path.dirname(distFile), { recursive: true }) - await fs.promises.writeFile(distFile, 'export const y = 1', { - encoding: 'utf8', - }) + const srcFile = path.join(tmpDir, 'packages', 'foo', 'src', 'index.ts') + await fs.promises.mkdir(path.dirname(srcFile), { recursive: true }) + await fs.promises.writeFile(srcFile, 'export const y = 1') const eventPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { watcher.off('all', onAll) resolve() - }, 2_000) + }, 500) const onAll = (eventName: string, filePath: string) => { try { @@ -438,21 +452,17 @@ describe('watchPaths', () => { const normalized = String(filePath).replace(/\\/g, '/') - if (normalized.includes('/packages/foo/dist/')) { + if (normalized.includes('/packages/foo/src/')) { clearTimeout(timeout) watcher.off('all', onAll) - reject( - new Error('dist edit triggered watcher event: ' + normalized), - ) + reject(new Error('src edit triggered watcher event: ' + normalized)) } } watcher.on('all', onAll) }) - await fs.promises.appendFile(distFile, '\n// update\n', { - encoding: 'utf8', - }) + await fs.promises.appendFile(srcFile, '\n// update\n') await eventPromise } finally { await watcher.close() diff --git a/packages/api-server/src/watchPaths.ts b/packages/api-server/src/watchPaths.ts index c395f64091..05c89a2ee1 100644 --- a/packages/api-server/src/watchPaths.ts +++ b/packages/api-server/src/watchPaths.ts @@ -63,13 +63,7 @@ async function workspacePackagesPaths() { const pkgJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) if (workspaceDepNames.has(pkgJson.name)) { - const srcDir = path.join(packageDir, 'src') - - if (fs.existsSync(srcDir)) { - packages.push(srcDir) - } else { - packages.push(packageDir) - } + packages.push(path.join(packageDir, 'dist')) } } } catch { @@ -87,7 +81,7 @@ function workspacePackagesIgnorePaths() { const packageIgnoredPaths: string[] = [] if (fs.existsSync(packagesDir)) { - packageIgnoredPaths.push(path.join(packagesDir, '*/dist')) + packageIgnoredPaths.push(path.join(packagesDir, '*/src')) } return packageIgnoredPaths From 651be9d70eeb20c141eb3a22ffacff5122d725eb Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 1 Feb 2026 22:21:36 +0100 Subject: [PATCH 12/12] updated tests --- .../src/__tests__/watchPaths.test.ts | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/api-server/src/__tests__/watchPaths.test.ts b/packages/api-server/src/__tests__/watchPaths.test.ts index 7fac825b84..bad052c398 100644 --- a/packages/api-server/src/__tests__/watchPaths.test.ts +++ b/packages/api-server/src/__tests__/watchPaths.test.ts @@ -373,13 +373,7 @@ describe('watchPaths', () => { }, 500) const onAll = (eventName: string, filePath: string) => { - try { - console.debug('chokidar event:', eventName, filePath) - } catch (e) { - console.debug('chokidar event logging failed', e) - } - - const normalized = String(filePath).replace(/\\/g, '/') + const normalized = importStatementPath(filePath) if (normalized.includes('/packages/foo/node_modules/')) { clearTimeout(timeout) @@ -405,22 +399,11 @@ describe('watchPaths', () => { it('ignores edits inside packages/foo/src', async () => { const patterns = await pathsToWatch() const ignoreFn = await getIgnoreFunction() - const wrappedIgnoreFn = vi.fn((path) => { - const returnValue = ignoreFn(path) - - if (returnValue) { - console.log(`Ignoring path: ${path}`) - } else { - console.log(`Not ignoring path: ${path}`) - } - - return returnValue - }) const watcher = chokidar.watch(patterns, { persistent: true, ignoreInitial: true, - ignored: wrappedIgnoreFn, + ignored: ignoreFn, }) watcher.on('error', (error) => { @@ -443,14 +426,8 @@ describe('watchPaths', () => { resolve() }, 500) - const onAll = (eventName: string, filePath: string) => { - try { - console.debug('chokidar event:', eventName, filePath) - } catch (e) { - console.debug('chokidar event logging failed', e) - } - - const normalized = String(filePath).replace(/\\/g, '/') + const onAll = (_eventName: string, filePath: string) => { + const normalized = importStatementPath(filePath) if (normalized.includes('/packages/foo/src/')) { clearTimeout(timeout)