@nestjs/cli
Version:
Nest - modern, fast, powerful node.js web framework (@cli)
278 lines (277 loc) • 14.4 kB
JavaScript
import * as chokidar from 'chokidar';
import { copyFileSync, mkdirSync, rmSync, statSync } from 'fs';
import { dirname, join, resolve as resolvePath, sep } from 'path';
import { ERROR_PREFIX } from '../ui/index.js';
import { copyPathResolve } from './helpers/copy-path-resolve.js';
import { globSync } from './helpers/glob.js';
import { getValueOrDefault } from './helpers/get-value-or-default.js';
import { areOutsidePathsAllowed, assertPathInsideProject, } from './helpers/path-confinement.js';
const ASSET_CHANGE_RESTART_DEBOUNCE_MS = 150;
export class AssetsManager {
watchAssetsKeyValue = {};
watchers = [];
watcherReadyPromises = [];
/**
* Using on `nest build` to close file watch or the build process will not end.
* Waits for all watchers to complete their initial scan before closing them,
* ensuring all assets are copied regardless of system speed.
*
* Returns a Promise that resolves once every watcher has been closed.
* Callers (e.g. `build.action.ts`, `swc-compiler.ts`) `await` this method,
* so it must surface the underlying close work — otherwise `await` resolves
* immediately while file watchers stay open and the build process can race
* its own exit, leaving handles dangling.
*/
async closeWatchers() {
await Promise.all(this.watcherReadyPromises);
await Promise.all(this.watchers.map((watcher) => watcher.close()));
}
copyAssets(configuration, appName, outDir, watchAssetsMode, onSuccess, rootDir) {
const assets = getValueOrDefault(configuration, 'compilerOptions.assets', appName) || [];
const includeLibraryAssets = getValueOrDefault(configuration, 'compilerOptions.includeLibraryAssets', appName) || [];
const libraryAssets = this.collectLibraryAssets(configuration, includeLibraryAssets, outDir);
if (assets.length <= 0 && libraryAssets.length <= 0) {
return;
}
try {
let sourceRoot = getValueOrDefault(configuration, 'sourceRoot', appName);
sourceRoot = join(process.cwd(), sourceRoot);
// The asset path stripping must mirror how the TypeScript compiler emits
// files. When the effective rootDir (either explicit or computed from
// input files) differs from the configured `sourceRoot` — typically the
// case when `--path` points at a tsconfig with a different rootDir — we
// use that rootDir instead so copied assets stay aligned with the
// emitted JavaScript output. See nestjs/nest-cli#3387.
const stripRoot = rootDir ? join(rootDir) : sourceRoot;
const filesToCopy = assets.map((item) => {
let includePath = typeof item === 'string' ? item : item.include;
let excludePath = typeof item !== 'string' && item.exclude ? item.exclude : undefined;
includePath = join(sourceRoot, includePath).replace(/\\/g, '/');
excludePath = excludePath
? join(sourceRoot, excludePath).replace(/\\/g, '/')
: undefined;
return {
outDir: typeof item !== 'string' ? item.outDir || outDir : outDir,
glob: includePath,
exclude: excludePath,
flat: typeof item !== 'string' ? item.flat : undefined, // deprecated field
watchAssets: typeof item !== 'string' ? item.watchAssets : undefined,
};
});
const allFilesToCopy = [...filesToCopy, ...libraryAssets];
const isWatchEnabled = getValueOrDefault(configuration, 'compilerOptions.watchAssets', appName) || watchAssetsMode;
const confinePathsToProject = !areOutsidePathsAllowed(configuration, appName);
if (confinePathsToProject) {
// Validate the configured destinations once, before any watcher is
// registered, so that a misconfigured "outDir" surfaces as a single
// build error rather than as a failure per copied file.
const seenOutDirs = new Set();
for (const item of allFilesToCopy) {
if (item.outDir && !seenOutDirs.has(item.outDir)) {
seenOutDirs.add(item.outDir);
assertPathInsideProject(item.outDir, 'assets[].outDir', {
resolveSymlinks: true,
});
}
}
}
// Debounce onSuccess so that a burst of asset changes (e.g. a git
// checkout touching many files at once) only triggers a single restart.
let debouncedOnSuccess;
if (onSuccess) {
let pending;
debouncedOnSuccess = () => {
if (pending) {
clearTimeout(pending);
}
pending = setTimeout(() => {
pending = undefined;
onSuccess();
}, ASSET_CHANGE_RESTART_DEBOUNCE_MS);
};
}
for (const item of allFilesToCopy) {
// Library assets carry their own `_sourceRoot` so the per-library
// directory layout is preserved verbatim. For application assets we
// strip relative to the effective tsconfig rootDir (falls back to
// sourceRoot when no rootDir is supplied or computable).
const itemSourceRoot = item._sourceRoot || stripRoot;
const option = {
action: 'change',
item,
path: '',
sourceRoot: itemSourceRoot,
watchAssetsMode: isWatchEnabled,
confinePathsToProject,
};
if (isWatchEnabled || item.watchAssets) {
const matchedPaths = globSync(item.glob, {
ignore: item.exclude,
dot: true,
});
// Chokidar does not emit the 'ready' event when given an empty
// array of paths, which causes closeWatchers() to hang forever
// on Promise.all(watcherReadyPromises). Skip the watcher and
// warn the user so the build can finish normally.
if (matchedPaths.length === 0) {
console.warn(`No files matched the asset pattern "${item.glob}". ` +
`Skipping watcher for this entry.`);
continue;
}
let ready = false;
// prettier-ignore
const watcher = chokidar
.watch(matchedPaths)
.on('add', (path) => {
this.safeActionOnFile({ ...option, path, action: 'change' });
if (ready && debouncedOnSuccess) {
debouncedOnSuccess();
}
})
.on('change', (path) => {
this.safeActionOnFile({ ...option, path, action: 'change' });
if (ready && debouncedOnSuccess) {
debouncedOnSuccess();
}
})
.on('unlink', (path) => {
this.safeActionOnFile({ ...option, path, action: 'unlink' });
if (ready && debouncedOnSuccess) {
debouncedOnSuccess();
}
});
let markSettled;
const settled = new Promise((resolve) => {
markSettled = resolve;
});
watcher.on('ready', () => {
ready = true;
markSettled();
});
// Without an 'error' listener chokidar rethrows as an uncaught
// exception (EMFILE, ENOSPC, EPERM on network shares), and the
// watcher never reaches 'ready' — which would leave closeWatchers()
// waiting on a promise that can no longer settle.
watcher.on('error', (error) => {
console.error(`${ERROR_PREFIX} Asset watcher failed for "${item.glob}". ` +
`${error instanceof Error ? error.message : String(error)}`);
markSettled();
});
this.watchers.push(watcher);
this.watcherReadyPromises.push(settled);
}
else {
const matchedPaths = globSync(item.glob, {
ignore: item.exclude,
dot: true,
});
const files = item.glob.endsWith('*')
? matchedPaths.filter((matched) => statSync(matched).isFile())
: matchedPaths.flatMap((matched) => {
if (statSync(matched).isDirectory()) {
return globSync(`${matched}/**/*`, {
ignore: item.exclude,
dot: true,
}).filter((file) => statSync(file).isFile());
}
return matched;
});
for (const path of files) {
this.actionOnFile({ ...option, path, action: 'change' });
}
}
}
}
catch (err) {
throw new Error(`An error occurred during the assets copying process. ${err.message}`, { cause: err });
}
}
collectLibraryAssets(configuration, libraryNames, outDir) {
if (!libraryNames.length || !configuration.projects) {
return [];
}
const result = [];
for (const libName of libraryNames) {
const libProject = configuration.projects[libName];
if (!libProject) {
continue;
}
const libAssets = libProject.compilerOptions?.assets;
if (!libAssets || libAssets.length <= 0) {
continue;
}
const libSourceRoot = join(process.cwd(), libProject.sourceRoot || libProject.root || '');
for (const item of libAssets) {
let includePath = typeof item === 'string' ? item : item.include;
let excludePath = typeof item !== 'string' && item.exclude ? item.exclude : undefined;
includePath = join(libSourceRoot, includePath).replace(/\\/g, '/');
excludePath = excludePath
? join(libSourceRoot, excludePath).replace(/\\/g, '/')
: undefined;
const entry = {
outDir: typeof item !== 'string' ? item.outDir || outDir : outDir,
glob: includePath,
exclude: excludePath,
flat: typeof item !== 'string' ? item.flat : undefined,
watchAssets: typeof item !== 'string' ? item.watchAssets : undefined,
_sourceRoot: libSourceRoot,
};
result.push(entry);
}
}
return result;
}
/**
* Runs `actionOnFile` from a watcher callback. A rejected destination must not
* escalate into an uncaught exception: chokidar emits synchronously, so a
* throw here would tear down the whole watch session.
*/
safeActionOnFile(option) {
try {
this.actionOnFile(option);
}
catch (err) {
console.error(`${ERROR_PREFIX} ${err instanceof Error ? err.message : String(err)}`);
}
}
actionOnFile(option) {
const { action, item, path, sourceRoot, watchAssetsMode, confinePathsToProject, } = option;
const isWatchEnabled = watchAssetsMode || item.watchAssets;
const assetCheckKey = path + (item.outDir ?? '');
// Allow to do action for the first time before check watchMode
if (!isWatchEnabled && this.watchAssetsKeyValue[assetCheckKey]) {
return;
}
// Set path value to true for watching the first time
this.watchAssetsKeyValue[assetCheckKey] = true;
let dest = copyPathResolve(path, item.outDir, sourceRoot.split(sep).length);
// When an "include" path resolves outside the strip root, stripping that
// root's segment count from the file's path can consume the filename
// itself, collapsing the destination to "outDir" — silently overwriting
// the output directory with the asset's contents before the compiler even
// runs. Fail loudly instead.
if (resolvePath(dest) === resolvePath(item.outDir)) {
throw new Error(`Refusing to copy "${path}" to "${item.outDir}": the "include" path resolves outside ` +
`"${sourceRoot}", so no path segment survives stripping and the file would overwrite ` +
`the "${item.outDir}" directory itself. Move the file inside "${sourceRoot}", or set ` +
`"rootDir" in your tsconfig to a directory that contains both it and your sources.`);
}
// The destination is re-checked per file: the configured "outDir" is
// validated up front, but a symlinked directory *inside* it would still
// redirect an individual write out of the project.
if (confinePathsToProject) {
dest = assertPathInsideProject(dest, 'assets[].outDir', {
resolveSymlinks: true,
});
}
// Copy to output dir if file is changed or added
if (action === 'change') {
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(path, dest);
}
else if (action === 'unlink') {
// Remove from output dir if file is deleted
rmSync(dest, { force: true });
}
}
}