From ed8e4e610d9ba3c481fd18da535ad2a375d773f0 Mon Sep 17 00:00:00 2001 From: Praveen Puglia Date: Mon, 5 Oct 2026 22:12:17 +0530 Subject: [PATCH 1/3] feat: start declaration watchers lazily in watch mode Skip outputs that are newer than their sources on watch startup and only start a package's TypeScript watch program once it is stale or edited, so idle packages in a monorepo-wide watch only cost a chokidar watcher. Co-Authored-By: Claude Opus 5.5 (1M context) --- Readme.md | 2 ++ src/cli/tsx-transform.ts | 21 +++++++++++++------- src/tools/ts-transformer/make.ts | 33 ++++++++++++++++++++++++++------ test/mixed-sources.test.mjs | 25 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/Readme.md b/Readme.md index 26b36f0..b5bcd65 100644 --- a/Readme.md +++ b/Readme.md @@ -100,6 +100,8 @@ These are dev-only files that consumers of the published package should never im When using `--watch`, the CLI monitors the source directory and processes changes **incrementally — per file**: +- On **startup**, only sources newer than their output (or without one) are re-transpiled. The TypeScript watch program, which loads every imported and `@types` file, starts only once the package's declarations are stale or a file changes. In a monorepo-wide `build:watch`, untouched packages stay idle. Run a normal build after changing Babel or TypeScript config, since outputs that look up to date are not rebuilt. + - On **edit** (`change`) of a `.ts`/`.tsx`, only that file is re-transpiled via Babel and written to `dist/`. A persistent `ts.createWatchProgram` instance re-emits the corresponding `.d.ts` (and `.d.ts.map`) for just the files it considers affected. - On **add** of a `.ts`/`.tsx`, the file is transpiled and TypeScript's watch program is updated with the new root file. - On **delete** (`unlink`) of a `.ts`/`.tsx`, the matching `.jsx`/`.js`, `.d.ts`, and `.d.ts.map` artifacts in `dist/` are removed. diff --git a/src/cli/tsx-transform.ts b/src/cli/tsx-transform.ts index 1868eb7..867f342 100644 --- a/src/cli/tsx-transform.ts +++ b/src/cli/tsx-transform.ts @@ -24,10 +24,12 @@ import { isTypeInput, getOutputPaths, validateOutputs, + hasStaleDeclarations, } from '../tools/ts-transformer/make.js'; import { generateDeclarationsNatively, startTypesWatcher, + type TypesWatcher, } from '../tools/types-generator/make.js'; import yoctoSpinner from 'yocto-spinner'; import chalk from 'chalk'; @@ -42,6 +44,7 @@ async function runInitialBuild(params: { // program's own initial emit covers it (and then handles // incremental updates for each edit). skipDeclarations?: boolean; + onlyStale?: boolean; }) { const spinner = yoctoSpinner({ spinner: { interval: 60, frames: ['🌕 ', '🌗 ', '🌑 '] }, @@ -56,6 +59,7 @@ async function runInitialBuild(params: { outDir: params.outDir, babelConfig: params.babelConfig, tsConfig: params.tsConfig, + onlyStale: params.onlyStale, }); if (!params.skipDeclarations) { generateDeclarationsNatively({ @@ -87,11 +91,12 @@ function startIncrementalWatchers(params: { tsConfig?: string; babelConfig?: string; }) { - const typesWatcher = startTypesWatcher({ - srcDir: params.srcDir, - outDir: params.outDir, - tsConfig: params.tsConfig, - }); + // A TS watch program loads the package plus every imported/@types file and + // watches all of them. Start it only once this package needs declarations, + // so idle packages in a monorepo-wide watch cost just a chokidar watcher. + let typesWatcher: TypesWatcher | undefined; + const types = () => (typesWatcher ??= startTypesWatcher(params)); + if (hasStaleDeclarations(params)) types(); function onUpsert(srcPath: string) { try { @@ -134,7 +139,7 @@ function startIncrementalWatchers(params: { if (fs.existsSync(srcPath)) { onUpsert(srcPath); if (isTypeInput(srcPath) || /\.jsx?$/.test(srcPath)) - typesWatcher.addFile(srcPath); + types().addFile(srcPath); } else { for (const output of getOutputPaths( srcPath, @@ -144,7 +149,7 @@ function startIncrementalWatchers(params: { if (!ownedOutputs.has(output)) fs.rmSync(output, { force: true }); } if (isTypeInput(srcPath) || /\.jsx?$/.test(srcPath)) - typesWatcher.removeFile(srcPath); + types().removeFile(srcPath); } } pending.clear(); @@ -251,6 +256,8 @@ const cli = defineCommand({ // initial pass, so the eager full-program pass would be // redundant double work. skipDeclarations: args.watch, + // Outputs newer than their sources are left alone on watch startup. + onlyStale: args.watch, }); } catch (error) { if (args.watch) { diff --git a/src/tools/ts-transformer/make.ts b/src/tools/ts-transformer/make.ts index 4c85f2e..1cdfb04 100644 --- a/src/tools/ts-transformer/make.ts +++ b/src/tools/ts-transformer/make.ts @@ -98,24 +98,44 @@ function validateOutputs( return new Set(owners.keys()); } +/** True when outPath is missing or older than srcPath. */ +function isStale(srcPath: string, outPath: string): boolean { + return ( + !fs.existsSync(outPath) || + fs.statSync(outPath).mtimeMs < fs.statSync(srcPath).mtimeMs + ); +} + +/** True when any TS implementation has a missing or outdated declaration. */ +function hasStaleDeclarations(params: { + srcDir: string; + outDir: string; + tsConfig?: string; +}): boolean { + return readBuildConfig(params).sourceFiles.some( + (srcPath) => + isTsSource(srcPath) && + isStale(srcPath, getOutputPaths(srcPath, params.srcDir, params.outDir)[1]) + ); +} + /** Transform implementations and copy JS, declarations and assets unchanged. */ function generateJavascriptFiles(params: { srcDir: string; outDir: string; babelConfig?: string; tsConfig?: string; + // Skip sources whose output is newer than the source (watch startup). + onlyStale?: boolean; }) { validateOutputs(params.srcDir, params.outDir, params.tsConfig); fs.mkdirSync(params.outDir, { recursive: true }); for (const srcPath of readBuildConfig(params).sourceFiles) { + const outPath = computeOutPath(srcPath, params.srcDir, params.outDir); + if (params.onlyStale && !isStale(srcPath, outPath)) continue; if (isTsSource(srcPath)) { - transformFile({ - srcPath, - outPath: computeOutPath(srcPath, params.srcDir, params.outDir), - babelConfig: params.babelConfig, - }); + transformFile({ srcPath, outPath, babelConfig: params.babelConfig }); } else { - const outPath = computeOutPath(srcPath, params.srcDir, params.outDir); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.copyFileSync(srcPath, outPath); } @@ -130,4 +150,5 @@ export { isTypeInput, getOutputPaths, validateOutputs, + hasStaleDeclarations, }; diff --git a/test/mixed-sources.test.mjs b/test/mixed-sources.test.mjs index 5d37a27..d7debd8 100644 --- a/test/mixed-sources.test.mjs +++ b/test/mixed-sources.test.mjs @@ -117,3 +117,28 @@ test('watch copies declaration changes and survives JS-to-TS migration', async ( ); assert.equal(watch.child.exitCode, null); }); + +test('watch skips up-to-date outputs and starts declarations lazily', async (t) => { + const f = await fixture(t); + await f.write('a.ts', 'export const a = 1;'); + await f.write('b.ts', 'export const b = 1;'); + const build = f.run(); + assert.equal((await build.closed)[0], 0, build.output()); + const untouched = (await fs.stat(path.join(f.dir, 'dist/b.js'))).mtimeMs; + const watch = f.run('--watch'); + await until( + async () => + watch.output().includes(`Watching ${path.join(f.dir, 'src')} for changes`), + 'watch ready' + ); + assert.doesNotMatch(watch.output(), /Starting compilation/); + await f.write('a.ts', 'export const a = "x";'); + await until( + async () => (await f.read('a.d.ts')).includes('"x"'), + 'lazy declaration update' + ); + assert.equal( + (await fs.stat(path.join(f.dir, 'dist/b.js'))).mtimeMs, + untouched + ); +}); From c75511f1338a9a28f87f449bb1565fb04871f764 Mon Sep 17 00:00:00 2001 From: Praveen Puglia Date: Mon, 5 Oct 2026 22:45:35 +0530 Subject: [PATCH 2/3] feat: watch all workspace packages from one process `tsx-transform --watch --workspace` at a workspace root watches every package whose build:watch runs bite, with that script's arguments. One process avoids exhausting inotify instances (EMFILE) and the ~125 MB baseline of a watcher process per package. Stale declarations at watch startup are now emitted by a one-off program instead of a long-lived watch program, so many packages can share a process; the watch program still starts on a package's first edit. Co-Authored-By: Claude Opus 5.5 (1M context) --- Readme.md | 15 +- src/cli/tsx-transform.ts | 289 +++++++++++++++++++----------- src/tools/types-generator/make.ts | 13 +- src/utils/build-config.ts | 8 +- src/utils/workspace.ts | 34 ++++ test/mixed-sources.test.mjs | 49 +++++ 6 files changed, 295 insertions(+), 113 deletions(-) diff --git a/Readme.md b/Readme.md index b5bcd65..1c33247 100644 --- a/Readme.md +++ b/Readme.md @@ -34,6 +34,7 @@ bite-tsx-transform --src ./lib --dist ./dist | `--src` | | string | Path to the source directory (default: `src/`) | ❌ No | | `--dist` | | string | Path to the output directory (default: `dist/`) | ❌ No | | `--watch` | `-w` | boolean | Enables watch mode | ❌ No | +| `--workspace` | | boolean | With `--watch`, watches all workspace packages in one process | ❌ No | | `--clean` | | boolean | Cleans the output directory before transpiling | ❌ No | | `--tsConfig` | | string | Path to custom `tsconfig.json` | ❌ No | | `--babelConfig` | | string | Path to custom `babel.config.json` | ❌ No | @@ -100,13 +101,25 @@ These are dev-only files that consumers of the published package should never im When using `--watch`, the CLI monitors the source directory and processes changes **incrementally — per file**: -- On **startup**, only sources newer than their output (or without one) are re-transpiled. The TypeScript watch program, which loads every imported and `@types` file, starts only once the package's declarations are stale or a file changes. In a monorepo-wide `build:watch`, untouched packages stay idle. Run a normal build after changing Babel or TypeScript config, since outputs that look up to date are not rebuilt. +- On **startup**, only sources newer than their output (or without one) are re-transpiled, and stale declarations are regenerated once. The TypeScript watch program, which loads every imported and `@types` file, starts on the package's first edit, so untouched packages stay idle. Run a normal build after changing Babel or TypeScript config, since outputs that look up to date are not rebuilt. - On **edit** (`change`) of a `.ts`/`.tsx`, only that file is re-transpiled via Babel and written to `dist/`. A persistent `ts.createWatchProgram` instance re-emits the corresponding `.d.ts` (and `.d.ts.map`) for just the files it considers affected. - On **add** of a `.ts`/`.tsx`, the file is transpiled and TypeScript's watch program is updated with the new root file. - On **delete** (`unlink`) of a `.ts`/`.tsx`, the matching `.jsx`/`.js`, `.d.ts`, and `.d.ts.map` artifacts in `dist/` are removed. - Non-TS files (assets) are copied/removed in the same way. +### Watching a monorepo + +Run `bite-tsx-transform --watch --workspace` at the workspace root instead of one watcher per package (e.g. `lerna run build:watch --parallel`): + +```json +{ "scripts": { "build:watch": "bite-tsx-transform --watch --workspace" } } +``` + +Packages come from `workspaces` in `package.json` (or `packages` in `lerna.json`; `dir/*` and plain directory patterns). Every package whose `build:watch` script runs `tsx-transform`/`bite-tsx-transform` is watched with that script's arguments, and log lines are prefixed with the package directory. A package that fails to start is logged and skipped. + +One process shares a single inotify instance, file-watcher set and copy of TypeScript and Babel. A watcher process per package can exhaust `fs.inotify.max_user_instances` on Linux (`EMFILE: too many open files`) and idles at roughly 125 MB per package. + Type errors are logged but **do not crash the watcher or wipe `dist/`** — fix and save to continue. If the **initial** (non-watch) build fails, the CLI still cleans the output directory and exits non-zero, matching the previous behavior. ## Logging & Error Handling diff --git a/src/cli/tsx-transform.ts b/src/cli/tsx-transform.ts index 867f342..6a7e4a5 100644 --- a/src/cli/tsx-transform.ts +++ b/src/cli/tsx-transform.ts @@ -7,7 +7,7 @@ import chokidar from 'chokidar'; // @ts-ignore import packageJson from '../../package.json' with { type: 'json' }; -import { defineCommand, runMain } from 'citty'; +import { defineCommand, parseArgs, runMain, type ArgsDef } from 'citty'; import { Logger } from '../utils/logger.js'; import { isExcludedSource } from '../utils/exclude.js'; @@ -16,6 +16,7 @@ import { isWithin, resolveBuildPath, } from '../utils/build-config.js'; +import { findWorkspacePackages } from '../utils/workspace.js'; import { generateJavascriptFiles, transformFile, @@ -45,13 +46,19 @@ async function runInitialBuild(params: { // incremental updates for each edit). skipDeclarations?: boolean; onlyStale?: boolean; + label?: string; }) { - const spinner = yoctoSpinner({ - spinner: { interval: 60, frames: ['🌕 ', '🌗 ', '🌑 '] }, - text: chalk.blue( - params.witty ? "🐬 Don't Panic, Too late" : '🐬 Transformation started!' - ), - }).start(); + // One process watches many packages with --workspace; skip the spinner. + const spinner = params.label + ? undefined + : yoctoSpinner({ + spinner: { interval: 60, frames: ['🌕 ', '🌗 ', '🌑 '] }, + text: chalk.blue( + params.witty + ? "🐬 Don't Panic, Too late" + : '🐬 Transformation started!' + ), + }).start(); try { generateJavascriptFiles({ @@ -69,18 +76,17 @@ async function runInitialBuild(params: { }); } - spinner.success( + spinner?.success( params.witty ? '🦄 Generated Mostly Harmless JS files' : '🦄 Transformation completed!' ); } catch (error) { - spinner.error( + spinner?.error( params.witty ? '🦄 What the photon did you just wrote ?' : '🐛 Transformation failed!' ); - Logger.Error(`Error building package:, ${error}`); throw error; } } @@ -90,13 +96,15 @@ function startIncrementalWatchers(params: { outDir: string; tsConfig?: string; babelConfig?: string; + label?: string; }) { + const tag = labelPrefix(params.label); // A TS watch program loads the package plus every imported/@types file and - // watches all of them. Start it only once this package needs declarations, - // so idle packages in a monorepo-wide watch cost just a chokidar watcher. + // watches all of them. Start it on the package's first edit, so idle + // packages in a monorepo-wide watch cost just a chokidar watcher. + // ponytail: programs stay alive once started; close idle ones if memory matters. let typesWatcher: TypesWatcher | undefined; const types = () => (typesWatcher ??= startTypesWatcher(params)); - if (hasStaleDeclarations(params)) types(); function onUpsert(srcPath: string) { try { @@ -114,10 +122,10 @@ function startIncrementalWatchers(params: { fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.copyFileSync(srcPath, outPath); } - Logger.Info(`Transformed ${path.relative(params.srcDir, srcPath)}`); + Logger.Info(`${tag}Transformed ${path.relative(params.srcDir, srcPath)}`); } catch (error) { Logger.Error( - `Failed to process ${path.relative(params.srcDir, srcPath)}: ${error}` + `${tag}Failed to process ${path.relative(params.srcDir, srcPath)}: ${error}` ); } } @@ -154,7 +162,7 @@ function startIncrementalWatchers(params: { } pending.clear(); } catch (error) { - Logger.Error(`Failed to update package: ${error}`); + Logger.Error(`${tag}Failed to update package: ${error}`); } } @@ -167,7 +175,9 @@ function startIncrementalWatchers(params: { .split(path.sep) .some((part) => part === 'node_modules' || part === '.git'), }) - .on('ready', () => Logger.Info(`Watching ${params.srcDir} for changes...`)) + .on('ready', () => + Logger.Info(`${tag}Watching ${params.srcDir} for changes...`) + ) .on('all', (event, srcPath) => { if ( !['add', 'change', 'unlink'].includes(event) || @@ -180,105 +190,178 @@ function startIncrementalWatchers(params: { }); } -const cli = defineCommand({ - meta: { - name: 'tsx-transform', - description: 'A CLI to transform TypeScript/TSX files to JavaScript.', - version: packageJson.version, +const args = { + src: { + type: 'string', + description: 'Path to the source directory', + required: false, + default: 'src', }, - args: { - src: { - type: 'string', - description: 'Path to the source directory', - required: false, - default: 'src', - }, - dist: { - type: 'string', - description: 'Path to dist directory', - }, - watch: { - type: 'boolean', - description: 'Enable watch mode', - alias: 'w', - }, - clean: { - type: 'boolean', - description: 'Clean the output directory before transpiling', - }, - version: { - type: 'boolean', - description: 'Show the CLI version', - }, - tsConfig: { - type: 'string', - description: 'Path to custom ts config', - }, - babelConfig: { - type: 'string', - description: 'Path to custom babel config', - }, - witty: { - type: 'boolean', - description: 'Try it out!', - }, + dist: { + type: 'string', + description: 'Path to dist directory', }, - async run({ args }) { - const srcDir = resolveBuildPath(args.src); - const outDir = resolveBuildPath( - args.dist ?? path.resolve(srcDir, '../dist') - ); + watch: { + type: 'boolean', + description: 'Enable watch mode', + alias: 'w', + }, + clean: { + type: 'boolean', + description: 'Clean the output directory before transpiling', + }, + version: { + type: 'boolean', + description: 'Show the CLI version', + }, + tsConfig: { + type: 'string', + description: 'Path to custom ts config', + }, + babelConfig: { + type: 'string', + description: 'Path to custom babel config', + }, + witty: { + type: 'boolean', + description: 'Try it out!', + }, + workspace: { + type: 'boolean', + description: + "With --watch at a workspace root: watch every package whose build:watch runs this CLI, using that script's arguments, in one process", + }, +} as const satisfies ArgsDef; - if (!fs.existsSync(srcDir)) { - Logger.Error(`Error: Source directory "${srcDir}" does not exist.`); - process.exit(1); - } +type PackageOptions = { + src: string; + dist?: string; + watch?: boolean; + clean?: boolean; + tsConfig?: string; + babelConfig?: string; + witty?: boolean; +}; - // Validate configuration and directory boundaries before any cleanup. - readBuildConfig({ srcDir, outDir, tsConfig: args.tsConfig }); +function labelPrefix(label?: string) { + return label ? chalk.dim(`[${label}] `) : ''; +} - if (args.clean) { - fs.rmSync(outDir, { recursive: true, force: true }); - } +/** Build one package, then watch it with --watch. Paths are relative to baseDir. */ +async function runPackage( + options: PackageOptions, + baseDir: string, + label?: string +) { + const resolve = (file?: string) => file && path.resolve(baseDir, file); + const srcDir = resolveBuildPath(resolve(options.src)!); + const outDir = resolveBuildPath( + resolve(options.dist) ?? path.resolve(srcDir, '../dist') + ); + const tsConfig = resolve(options.tsConfig); + const babelConfig = resolve(options.babelConfig); + + if (!fs.existsSync(srcDir)) { + throw new Error(`Source directory "${srcDir}" does not exist.`); + } - if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); + // Validate configuration and directory boundaries before any cleanup. + readBuildConfig({ srcDir, outDir, tsConfig }); + + if (options.clean) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + try { + await runInitialBuild({ + srcDir, + outDir, + babelConfig, + tsConfig, + witty: options.witty, + label, + // Watch mode starts the TS watch program on the first edit, so only + // stale declarations are emitted up front, in a one-off program that + // is released afterwards. + skipDeclarations: + options.watch && !hasStaleDeclarations({ srcDir, outDir, tsConfig }), + // Outputs newer than their sources are left alone on watch startup. + onlyStale: options.watch, + }); + } catch (error) { + if (!options.watch) { + if (fs.existsSync(outDir)) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + throw error; } + Logger.Error(`${labelPrefix(label)}Error building package: ${error}`); + Logger.Warning( + `${labelPrefix(label)}Initial build failed. Watch mode is active — fix the error and save to retry.` + ); + } + + if (options.watch) { + startIncrementalWatchers({ srcDir, outDir, babelConfig, tsConfig, label }); + } +} + +// The bite command and its arguments within a package script. +const BITE_COMMAND = /(?:^|[\s;&|(])(?:bite-)?tsx-transform\b([^;&|)]*)/; +/** + * Watch every workspace package in this process. One process shares one + * inotify instance and one copy of TypeScript and Babel; a process per + * package exhausts fs.inotify.max_user_instances (EMFILE) in large repos. + */ +async function runWorkspace(root: string) { + let watched = 0; + for (const dir of findWorkspacePackages(root)) { + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ); + const match = BITE_COMMAND.exec(pkg.scripts?.['build:watch'] ?? ''); + if (!match) continue; + const label = path.basename(dir); try { - await runInitialBuild({ - srcDir, - outDir, - babelConfig: args.babelConfig, - tsConfig: args.tsConfig, - witty: args.witty, - // In watch mode, the TS watch program emits .d.ts on its own - // initial pass, so the eager full-program pass would be - // redundant double work. - skipDeclarations: args.watch, - // Outputs newer than their sources are left alone on watch startup. - onlyStale: args.watch, - }); + const options = parseArgs( + match[1].trim().split(/\s+/).filter(Boolean), + args + ); + await runPackage({ ...options, watch: true }, dir, label); + watched++; } catch (error) { - if (args.watch) { - Logger.Warning( - 'Initial build failed. Watch mode is active — fix the error and save to retry.' - ); - } else { - if (fs.existsSync(outDir)) { - fs.rmSync(outDir, { recursive: true, force: true }); - } + Logger.Error(`${labelPrefix(label)}${error}`); + } + } + Logger.Info(`Watching ${watched} packages for changes...`); +} + +const cli = defineCommand({ + meta: { + name: 'tsx-transform', + description: 'A CLI to transform TypeScript/TSX files to JavaScript.', + version: packageJson.version, + }, + args, + async run({ args }) { + if (args.workspace) { + if (!args.watch) { + Logger.Error('--workspace is only supported with --watch.'); process.exit(1); } + await runWorkspace(process.cwd()); + return; } - - if (args.watch) { - startIncrementalWatchers({ - srcDir, - outDir, - babelConfig: args.babelConfig, - tsConfig: args.tsConfig, - }); + try { + await runPackage(args, process.cwd()); + } catch (error) { + Logger.Error(`Error building package: ${error}`); + process.exit(1); } }, }); diff --git a/src/tools/types-generator/make.ts b/src/tools/types-generator/make.ts index b7182b0..1ff72fb 100644 --- a/src/tools/types-generator/make.ts +++ b/src/tools/types-generator/make.ts @@ -156,17 +156,18 @@ function startRawWatchProgram( outDir: string, params: BuildParams ) { + const tag = params.label ? `[${params.label}] ` : ''; const host = ts.createWatchCompilerHost( rootFiles, compilerOptions, createOutDirAwareSystem(outDir), ts.createEmitAndSemanticDiagnosticsBuilderProgram, (diagnostic) => { - Logger.Error(formatDiagnostic(diagnostic)); + Logger.Error(tag + formatDiagnostic(diagnostic)); }, (diagnostic) => { Logger.Info( - ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + tag + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') ); } ); @@ -188,7 +189,7 @@ function startRawWatchProgram( validateOutputs(params.srcDir, outDir, params.tsConfig); afterProgramCreate?.(program); } catch (error) { - Logger.Error(String(error)); + Logger.Error(tag + String(error)); } }; return ts.createWatchProgram(host); @@ -220,11 +221,7 @@ function formatDiagnostic(diagnostic: ts.Diagnostic): string { * for newly-added files. `change` events don't need a call — TS's own * file watcher sees them. */ -function startTypesWatcher(params: { - srcDir: string; - outDir: string; - tsConfig?: string; -}): TypesWatcher { +function startTypesWatcher(params: BuildParams): TypesWatcher { let rootFiles = getProgramRootFiles(params); const compilerOptions = readBuildConfig(params).options; diff --git a/src/utils/build-config.ts b/src/utils/build-config.ts index a568e3a..6f71b37 100644 --- a/src/utils/build-config.ts +++ b/src/utils/build-config.ts @@ -4,7 +4,13 @@ import ts from 'typescript'; import { isExcludedSource } from './exclude.js'; -export type BuildParams = { srcDir: string; outDir: string; tsConfig?: string }; +export type BuildParams = { + srcDir: string; + outDir: string; + tsConfig?: string; + // Prefixes log lines when one process watches several packages. + label?: string; +}; // Resolve existing ancestors too, so a not-yet-created dist through a symlink // is compared against the same physical source directory before --clean. diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 0017d6c..5233c58 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -98,3 +98,37 @@ export function getWorkspaceAmbientFiles(srcDir: string): string[] { .map((entry) => path.join(root, entry.name)) .filter((p) => !looksLikeToolingEnvFile(p)); } + +/** + * Package directories of the workspace rooted at `root`, from `workspaces` + * in package.json, else `packages` in lerna.json. + */ +export function findWorkspacePackages(root: string): string[] { + const read = (file: string) => + fs.existsSync(path.join(root, file)) + ? JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')) + : {}; + const { workspaces } = read('package.json'); + const patterns: string[] = + (Array.isArray(workspaces) ? workspaces : workspaces?.packages) ?? + read('lerna.json').packages ?? + []; + if (!patterns.length) { + throw new Error( + `No workspace packages in ${root}: add "workspaces" to package.json or "packages" to lerna.json.` + ); + } + // ponytail: supports `dir/*` and plain directories, the common shapes; use a + // glob library if workspaces need `**` or negated patterns. + return patterns + .flatMap((pattern) => { + if (!pattern.endsWith('/*')) return [path.resolve(root, pattern)]; + const parent = path.resolve(root, pattern.slice(0, -2)); + if (!fs.existsSync(parent)) return []; + return fs + .readdirSync(parent, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(parent, entry.name)); + }) + .filter((dir) => fs.existsSync(path.join(dir, 'package.json'))); +} diff --git a/test/mixed-sources.test.mjs b/test/mixed-sources.test.mjs index d7debd8..8cd3875 100644 --- a/test/mixed-sources.test.mjs +++ b/test/mixed-sources.test.mjs @@ -142,3 +142,52 @@ test('watch skips up-to-date outputs and starts declarations lazily', async (t) untouched ); }); + +test('workspace watch runs every bite package in one process', async (t) => { + const f = await fixture(t); + const put = async (name, contents) => { + const file = path.join(f.dir, name); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile( + file, + typeof contents === 'string' ? contents : JSON.stringify(contents) + ); + }; + const exists = (name) => + fs.access(path.join(f.dir, name)).then( + () => true, + () => false + ); + await put('package.json', { private: true, workspaces: ['packages/*'] }); + await put('packages/a/package.json', { + scripts: { 'build:watch': 'tsx-transform --watch' }, + }); + await put('packages/a/src/a.ts', 'export const a = 1;'); + await put('packages/b/package.json', { + scripts: { 'build:watch': 'tsx-transform --watch --src=lib --dist=out' }, + }); + await put('packages/b/lib/b.tsx', 'export const b =
;'); + await put('packages/c/package.json', { + scripts: { 'build:watch': 'webpack --watch' }, + }); + await put('packages/c/src/c.ts', 'export const c = 1;'); + const watch = f.run('--watch', '--workspace'); + await until( + async () => + watch.output().includes('Watching 2 packages') && + (await exists('packages/a/dist/a.d.ts')) && + (await exists('packages/b/out/b.jsx')), + 'workspace ready' + ); + assert.equal(await exists('packages/c/dist'), false); + await put('packages/b/lib/b.tsx', 'export const b = "changed";'); + await until( + async () => + ( + await fs.readFile(path.join(f.dir, 'packages/b/out/b.d.ts'), 'utf8') + ).includes('"changed"'), + 'declaration update in a non-default layout' + ); + assert.match(watch.output(), /\[b\] Transformed b\.tsx/); + assert.equal(watch.child.exitCode, null); +}); From e84832d910b45ff21c369835fce287646e380bad Mon Sep 17 00:00:00 2001 From: Praveen Puglia Date: Mon, 5 Oct 2026 22:55:19 +0530 Subject: [PATCH 3/3] fix: report packages as watched only once chokidar is ready The workspace summary was logged before each package's initial scan finished, so an edit right after it could be missed. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/cli/tsx-transform.ts | 56 +++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/src/cli/tsx-transform.ts b/src/cli/tsx-transform.ts index 6a7e4a5..f1f1144 100644 --- a/src/cli/tsx-transform.ts +++ b/src/cli/tsx-transform.ts @@ -166,28 +166,32 @@ function startIncrementalWatchers(params: { } } - chokidar - .watch(params.srcDir, { - ignoreInitial: true, - ignored: (file) => - isWithin(params.outDir, file) || - file - .split(path.sep) - .some((part) => part === 'node_modules' || part === '.git'), - }) - .on('ready', () => - Logger.Info(`${tag}Watching ${params.srcDir} for changes...`) - ) - .on('all', (event, srcPath) => { - if ( - !['add', 'change', 'unlink'].includes(event) || - isExcludedSource(srcPath) - ) - return; - pending.add(srcPath); - clearTimeout(timer); - timer = setTimeout(flush, 100); - }); + // Resolve once the initial scan finishes; earlier edits would be missed. + return new Promise((resolve) => + chokidar + .watch(params.srcDir, { + ignoreInitial: true, + ignored: (file) => + isWithin(params.outDir, file) || + file + .split(path.sep) + .some((part) => part === 'node_modules' || part === '.git'), + }) + .on('ready', () => { + Logger.Info(`${tag}Watching ${params.srcDir} for changes...`); + resolve(); + }) + .on('all', (event, srcPath) => { + if ( + !['add', 'change', 'unlink'].includes(event) || + isExcludedSource(srcPath) + ) + return; + pending.add(srcPath); + clearTimeout(timer); + timer = setTimeout(flush, 100); + }) + ); } const args = { @@ -306,7 +310,13 @@ async function runPackage( } if (options.watch) { - startIncrementalWatchers({ srcDir, outDir, babelConfig, tsConfig, label }); + await startIncrementalWatchers({ + srcDir, + outDir, + babelConfig, + tsConfig, + label, + }); } }