575 lines
14 KiB
TypeScript

import { joinPathFragments } from '@nrwl/devkit/src/utils/path';
import { watch } from 'chokidar';
import { createHash } from 'crypto';
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
import { copySync, ensureDirSync } from 'fs-extra';
import * as http from 'http';
import ignore from 'ignore';
import * as open from 'open';
import { dirname, join, normalize, parse } from 'path';
import { performance } from 'perf_hooks';
import { URL } from 'url';
import { workspaceLayout } from '../core/file-utils';
import { defaultFileHasher } from '../core/hasher/file-hasher';
import {
createProjectGraph,
onlyWorkspaceProjects,
ProjectGraph,
ProjectGraphDependency,
ProjectGraphNode,
} from '../core/project-graph';
import { appRootPath } from '../utilities/app-root';
import {
cacheDirectory,
readCacheDirectoryProperty,
} from '../utilities/cache-directory';
import { writeJsonFile } from '../utilities/fileutils';
import { output } from '../utilities/output';
export interface DepGraphClientProject {
name: string;
type: string;
data: {
tags: string[];
root: string;
};
}
export interface DepGraphClientResponse {
hash: string;
projects: DepGraphClientProject[];
dependencies: Record<string, ProjectGraphDependency[]>;
layout: { appsDir: string; libsDir: string };
changes: {
added: string[];
};
affected: string[];
focus: string;
groupByFolder: boolean;
exclude: string[];
}
// maps file extention to MIME types
const mimeType = {
'.ico': 'image/x-icon',
'.html': 'text/html',
'.js': 'text/javascript',
'.json': 'application/json',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.eot': 'appliaction/vnd.ms-fontobject',
'.ttf': 'aplication/font-sfnt',
};
const nxDepsDir = cacheDirectory(
appRootPath,
readCacheDirectoryProperty(appRootPath)
);
function projectsToHtml(
projects: ProjectGraphNode[],
graph: ProjectGraph,
affected: string[],
focus: string,
groupByFolder: boolean,
exclude: string[],
layout: { appsDir: string; libsDir: string },
localMode: 'serve' | 'build',
watchMode: boolean = false
) {
let f = readFileSync(
join(__dirname, '../core/dep-graph/index.html'),
'utf-8'
);
f = f
.replace(
`window.projects = []`,
`window.projects = ${JSON.stringify(projects)}`
)
.replace(`window.graph = {}`, `window.graph = ${JSON.stringify(graph)}`)
.replace(
`window.affected = []`,
`window.affected = ${JSON.stringify(affected)}`
)
.replace(
`window.groupByFolder = false`,
`window.groupByFolder = ${!!groupByFolder}`
)
.replace(
`window.exclude = []`,
`window.exclude = ${JSON.stringify(exclude)}`
)
.replace(
`window.workspaceLayout = null`,
`window.workspaceLayout = ${JSON.stringify(layout)}`
);
if (focus) {
f = f.replace(
`window.focusedProject = null`,
`window.focusedProject = '${focus}'`
);
}
if (watchMode) {
f = f.replace(`window.watch = false`, `window.watch = true`);
}
if (localMode === 'build') {
currentDepGraphClientResponse = createDepGraphClientResponse();
f = f.replace(
`window.projectGraphResponse = null`,
`window.projectGraphResponse = ${JSON.stringify(
currentDepGraphClientResponse
)}`
);
f = f.replace(`window.localMode = 'serve'`, `window.localMode = 'build'`);
}
return f;
}
function projectExists(projects: ProjectGraphNode[], projectToFind: string) {
return (
projects.find((project) => project.name === projectToFind) !== undefined
);
}
function hasPath(
graph: ProjectGraph,
target: string,
node: string,
visited: string[]
) {
if (target === node) return true;
for (let d of graph.dependencies[node] || []) {
if (visited.indexOf(d.target) > -1) continue;
visited.push(d.target);
if (hasPath(graph, target, d.target, visited)) return true;
}
return false;
}
function filterGraph(
graph: ProjectGraph,
focus: string,
exclude: string[]
): ProjectGraph {
let projectNames = (Object.values(graph.nodes) as ProjectGraphNode[]).map(
(project) => project.name
);
let filteredProjectNames: Set<string>;
if (focus !== null) {
filteredProjectNames = new Set<string>();
projectNames.forEach((p) => {
const isInPath =
hasPath(graph, p, focus, []) || hasPath(graph, focus, p, []);
if (isInPath) {
filteredProjectNames.add(p);
}
});
} else {
filteredProjectNames = new Set<string>(projectNames);
}
if (exclude.length !== 0) {
exclude.forEach((p) => filteredProjectNames.delete(p));
}
let filteredGraph: ProjectGraph = {
nodes: {},
dependencies: {},
};
filteredProjectNames.forEach((p) => {
filteredGraph.nodes[p] = graph.nodes[p];
filteredGraph.dependencies[p] = graph.dependencies[p];
});
return filteredGraph;
}
export function generateGraph(
args: {
file?: string;
host?: string;
port?: number;
focus?: string;
exclude?: string[];
groupByFolder?: boolean;
watch?: boolean;
},
affectedProjects: string[]
): void {
let graph = onlyWorkspaceProjects(createProjectGraph());
const layout = workspaceLayout();
const projects = Object.values(graph.nodes) as ProjectGraphNode[];
projects.sort((a, b) => {
return a.name.localeCompare(b.name);
});
if (args.focus) {
if (!projectExists(projects, args.focus)) {
output.error({
title: `Project to focus does not exist.`,
bodyLines: [`You provided --focus=${args.focus}`],
});
process.exit(1);
}
}
if (args.exclude) {
const invalidExcludes: string[] = [];
args.exclude.forEach((project) => {
if (!projectExists(projects, project)) {
invalidExcludes.push(project);
}
});
if (invalidExcludes.length > 0) {
output.error({
title: `The following projects provided to --exclude do not exist:`,
bodyLines: invalidExcludes,
});
process.exit(1);
}
}
let html: string;
if (!args.file || args.file.endsWith('html')) {
html = projectsToHtml(
projects,
graph,
affectedProjects,
args.focus || null,
args.groupByFolder || false,
args.exclude || [],
layout,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve',
args.watch
);
} else {
graph = filterGraph(graph, args.focus || null, args.exclude || []);
}
if (args.file) {
let folder = appRootPath;
let filename = args.file;
let ext = args.file.replace(/^.*\.(.*)$/, '$1');
if (ext === 'html') {
if (filename.includes('/')) {
const [_match, _folder, _file] = /^(.*)\/([^/]*\.(.*))$/.exec(
args.file
);
folder = `${appRootPath}/${_folder}`;
filename = _file;
}
filename = `${folder}/${filename}`;
const assetsFolder = `${folder}/static`;
const assets: string[] = [];
copySync(join(__dirname, '../core/dep-graph'), assetsFolder, {
filter: (_src, dest) => {
const isntHtml = !/index\.html/.test(dest);
if (isntHtml && dest.includes('.')) {
assets.push(dest);
}
return isntHtml;
},
});
currentDepGraphClientResponse = createDepGraphClientResponse();
html = html.replace(/src="/g, 'src="static/');
html = html.replace(/href="styles/g, 'href="static/styles');
html = html.replace('<base href="/">', '');
html = html.replace(/type="module"/g, '');
writeFileSync(filename, html);
output.success({
title: `HTML output created in ${folder}`,
bodyLines: [filename, ...assets],
});
} else if (ext === 'json') {
filename = `${folder}/${filename}`;
ensureDirSync(dirname(filename));
writeJsonFile(filename, {
graph,
affectedProjects,
criticalPath: affectedProjects,
});
output.success({
title: `JSON output created in ${folder}`,
bodyLines: [filename],
});
} else {
output.error({
title: `Please specify a filename with either .json or .html extension.`,
bodyLines: [`You provided --file=${args.file}`],
});
process.exit(1);
}
} else {
startServer(
html,
args.host || '127.0.0.1',
args.port || 4211,
args.watch,
affectedProjects,
args.focus,
args.groupByFolder,
args.exclude
);
}
}
function startServer(
html: string,
host: string,
port = 4211,
watchForchanges = false,
affected: string[] = [],
focus: string = null,
groupByFolder: boolean = false,
exclude: string[] = []
) {
if (watchForchanges) {
startWatcher();
}
currentDepGraphClientResponse = createDepGraphClientResponse();
currentDepGraphClientResponse.affected = affected;
currentDepGraphClientResponse.focus = focus;
currentDepGraphClientResponse.groupByFolder = groupByFolder;
currentDepGraphClientResponse.exclude = exclude;
const app = http.createServer((req, res) => {
// parse URL
const parsedUrl = new URL(req.url, `http://${host}:${port}`);
// extract URL path
// Avoid https://en.wikipedia.org/wiki/Directory_traversal_attack
// e.g curl --path-as-is http://localhost:9000/../fileInDanger.txt
// by limiting the path to current directory only
const sanitizePath = normalize(parsedUrl.pathname).replace(
/^(\.\.[\/\\])+/,
''
);
if (sanitizePath === '/projectGraph.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(currentDepGraphClientResponse));
return;
}
if (sanitizePath === '/currentHash') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hash: currentDepGraphClientResponse.hash }));
return;
}
let pathname = join(__dirname, '../core/dep-graph/', sanitizePath);
if (!existsSync(pathname)) {
// if the file is not found, return 404
res.statusCode = 404;
res.end(`File ${pathname} not found!`);
return;
}
// if is a directory, then look for index.html
if (statSync(pathname).isDirectory()) {
// pathname += '/index.html';
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
return;
}
try {
const data = readFileSync(pathname);
const ext = parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.end(data);
} catch (err) {
res.statusCode = 500;
res.end(`Error getting the file: ${err}.`);
}
});
app.listen(port, host);
output.note({
title: `Dep graph started at http://${host}:${port}`,
});
open(`http://${host}:${port}`);
}
let currentDepGraphClientResponse: DepGraphClientResponse = {
hash: null,
projects: [],
dependencies: {},
layout: {
appsDir: '',
libsDir: '',
},
changes: {
added: [],
},
affected: [],
focus: null,
groupByFolder: false,
exclude: [],
};
function getIgnoredGlobs(root: string) {
const ig = ignore();
try {
ig.add(readFileSync(`${root}/.gitignore`, 'utf-8'));
} catch {}
try {
ig.add(readFileSync(`${root}/.nxignore`, 'utf-8'));
} catch {}
return ig;
}
function startWatcher() {
createFileWatcher(appRootPath, () => {
output.note({ title: 'Recalculating dependency graph...' });
const newGraphClientResponse = createDepGraphClientResponse();
if (newGraphClientResponse.hash !== currentDepGraphClientResponse.hash) {
output.note({ title: 'Graph changes updated.' });
currentDepGraphClientResponse = newGraphClientResponse;
} else {
output.note({ title: 'No graph changes found.' });
}
});
}
function debounce(fn: (...args) => void, time: number) {
let timeout: NodeJS.Timeout;
return (...args) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => fn(...args), time);
};
}
function createFileWatcher(root: string, changeHandler: () => void) {
const ignoredGlobs = getIgnoredGlobs(root);
const layout = workspaceLayout();
const watcher = watch(
[
joinPathFragments(layout.appsDir, '**'),
joinPathFragments(layout.libsDir, '**'),
],
{
cwd: root,
ignoreInitial: true,
}
);
watcher.on(
'all',
debounce((event: string, path: string) => {
if (ignoredGlobs.ignores(path)) return;
changeHandler();
}, 500)
);
return { close: () => watcher.close() };
}
function createDepGraphClientResponse(): DepGraphClientResponse {
performance.mark('dep graph watch calculation:start');
defaultFileHasher.clear();
defaultFileHasher.init();
let graph = onlyWorkspaceProjects(createProjectGraph());
performance.mark('dep graph watch calculation:end');
performance.mark('dep graph response generation:start');
const layout = workspaceLayout();
const projects: DepGraphClientProject[] = Object.values(graph.nodes).map(
(project) => ({
name: project.name,
type: project.type,
data: {
tags: project.data.tags,
root: project.data.root,
},
})
);
const dependencies = graph.dependencies;
const hasher = createHash('sha256');
hasher.update(JSON.stringify({ layout, projects, dependencies }));
const hash = hasher.digest('hex');
let added = [];
if (
currentDepGraphClientResponse.hash !== null &&
hash !== currentDepGraphClientResponse.hash
) {
added = projects
.filter((project) => {
const result = currentDepGraphClientResponse.projects.find(
(previousProject) => previousProject.name === project.name
);
return !result;
})
.map((project) => project.name);
}
performance.mark('dep graph response generation:end');
performance.measure(
'dep graph watch calculation',
'dep graph watch calculation:start',
'dep graph watch calculation:end'
);
performance.measure(
'dep graph response generation',
'dep graph response generation:start',
'dep graph response generation:end'
);
return {
...currentDepGraphClientResponse,
hash,
layout,
projects,
dependencies,
changes: {
added: [...currentDepGraphClientResponse.changes.added, ...added],
},
};
}