How to Install a Node.js Project Within Composer


At DrupalCon Vienna, I presented a way to install a node package using composer to use inside a Drupal 8 site. It’s a great way to include a bit of decoupling into your project (and as you may know,
we’re really into that here).

The Ingredients

Here’s the step by step breakdown of what you need to do. Note: these steps assume you already have the following:

  • A Drupal 8 site built with composer, using our Skeletor framework or Drupal-Composer with a composer.json file in the root.
  • A Node.js application hosted on a separate repository that only requires the command "npm install" to compile.
  • A basic understanding of using composer as a dependency manager.


woman at computer reviewing work

The Recipe

Step 1: Register the Node.js Repository

Composer can pull almost anything down into it’s vendor directory, so let’s start with grabbing your node repository. Here, we’re going to have a project called "my-org/react-app", which has a public repository at [email protected]:my-org/react-app.git and has a master branch.

Open the root composer.json file and register your repository with the following code under 'repositories'.

"repositories": [
  {
    "type": "package",
    "package": {
      "name": "my-org/react-app",
      "version": "1.0",
      "type": "package",
      "source": {
        "type": "git",
        "reference": "presentation",
        "url": "[email protected]:my-org/react-app.git"
      }
}
  }
],

Now we can add it as a composer dependency by typing the following into your terminal. It will automatically download.

$ composer require my-org/react-app


If you have a lot of these dependencies, you can
create a private version of Packagist to host them instead of registering each one.


Step 2: Move your node dependencies into their own directory

At this point, we’re downloading react-app into the vendor folder for packages, and we can’t really get to it from Drupal, which is a problem if we’re trying to attach the built file to a page controller or a block, for example.


What we need is for composer to place this specific package in a specific location that is accessible for Drupal to pull in the node application from. Drupal does this already with modules, so we can use the same pattern. We’ll be using the oomphinc/composer-installer-extender package to register a custom package type, and then tell composer to place those packages in modules/npm-packages.

$ composer require oomphinc/composer-installer-extender


Add our custom package type and location by adding this under extras in your composer.json

"extra": {
 "installer-types": ["npm-package"],
 "installer-paths": {
    "docroot/modules/npm-packages/{$name}": ["type:npm-package"]
 …


Finally, tell composer our custom node package is an npm-package by editing our repositories again:

"repositories": [
  {
    "type": "package",
    "package": {
      "name": "my-org/react-app",
      "version": "1.0",
      "type": "npm-package",
      "source": {
        "type": "git",
        "reference": "presentation",
        "url": "[email protected]:my-org/react-app.git"
      }
}
    …


Bam! Run the following in your terminal and you should see your node app in docroot/modules/npm-packages

$ composer install


Step 3: Write a Script to install Node

Well, we downloaded and placed our node application, but it still isn’t installed yet. We don’t have that final app.js file that we want to include with Drupal. We’ll need to create a custom php script that composer can run to do all of these things.

We use a helper package called DrupalFinder to assist with finding the node packages, it’s the same one used in Drush and Drupal Console. (So fancy!) Install it before you begin with the following.

$ composer require webflo/drupal-finder


If you’re using drupal-composer or skeletor, you’ll have a scripts/ directory beside your docroot. Create a file at
scripts/NpmPackage.php and populate it with the following. You can also find a full version of this script inside Skeletor.

<?php

namespace DrupalSkeletor;

use Composer\Script\Event;
use Symfony\Component\Finder\Finder;
use DrupalFinder\DrupalFinder;

/**
* Class NpmPackage.
*
* @package DrupalSkeletor
*/
class NpmPackage {

 /**
  * NPM Install.
  *
  * @param \Composer\Script\Event $event
  *   Event to echo output.
  */
 public static function npmInstall(Event $event) {
static::runNpmCommand('install', $event);
 }

 /**
  * Get Package Root.
  *
  * @return string
  *   Path to Drupal Root.
  */
 protected static function getPackageRoot() {
$drupalFinder = new DrupalFinder();
if ($drupalFinder->locateRoot(getcwd())) {
  return $drupalFinder->getDrupalRoot();
}
 }

 /**
  * Find the NPM packages, excluding some directories.
  *
  * @param string $packageRoot
  *   Path to Drupal Root.
  *
  * @return array
  *   Array of found package.json files.
  */
  protected static function findNpmPackages($packageRoot) {
// Find all npm instances in the package root,
// that are not contained in contrib, vendor or node_module directories.
$finder = new Finder();
return
$finder->in($packageRoot)->notPath("/core|contrib|vendor|node_modules/")->name("package.json");

 }

 /**
  * Helper to run arbitrary npm scripts.
  *
  * @param string $command
  *   Command to run.
  * @param \Composer\Script\Event $event
  *   Event to echo output.
  */
 protected static function runNpmCommand($command, Event $event) {
$packageRoot = static::getPackageRoot();
foreach (static::findNpmPackages($packageRoot) as $package) {
  $path = $package->getRelativePath();
  $event->getIO()->write(" Running npm $command in $path");
  exec("cd $packageRoot/$path; npm $command");
}
  }
}

This script crawls through your docroot and looks for npm apps to install, using the DrupalFinder and Symfony Finder. You extend it to run any npm script you need by creating a new method that calls
 "static::runNpmCommand('your command', $event);"

We use static methods since we’re going to need to call them explicitly during our composer install later on. You’ll see!

Step 4: Have Composer Install the Node Package

Now that we’ve created this helper script, we’ll need to make sure that composer calls it when we download our apps. Add our new file to under autoload in your composer.json to make sure it’s accessible for composer.

"autoload": {
 "classmap": [
   "scripts/composer/ScriptHandler.php",
   "scripts/NpmPackage.php"
    …


With the script loaded, we can register a new composer command by adding this under scripts.

"scripts": {
 "npm-install": "DrupalSkeletor\\NpmPackage::npmInstall",
 …


Try it out!

$ composer npm-install
> DrupalSkeletor\NpmPackage::npmInstall
   Running npm install in modules/npm-packages/react-app


Composer should now crawl through your docroot and install the package. (Wa-hoo!) 
But wait... There’s more!

We can also do this automatically when composer downloads everything. Composer has "post install commands" that are scripts that run after install. (You can learn more about the commands here) Ensure that npm-install is run after your standard composer install by adding it under post-install-cmd.

"scripts": {
 "npm-install": "DrupalSkeletor\\NpmPackage::npmInstall",
 "post-install-cmd": [
   "DrupalProject\\composer\\ScriptHandler::createRequiredFiles",
   "DrupalComposer\\DrupalScaffold\\Plugin::scaffold",
   "@composer npm-install"
 ]
 …


Step 5: Profit

Congrats! That’s it! Once you have this frame work up and going, you can install any number of node packages in your composer project to use as you need. Why not try more progressive decoupling?

The Result

We need to start incorporating our “traditional” Drupal build with a greater variety of frameworks, especially with Drupal taking its relationship with JavaScript to the next level. Progressive decoupling is a great way to start making that first step with your team, since it allows Drupal to do the heavy lifting and JavaScript components enhance existing functionality.

By simplifying the DevOps around decoupled development, we reduce barriers for developers to explore new technologies.

So go! Have fun! Try something new. The Drupal Island has always been a great place, but there’s just no telling how far we’ll go.

 

Join us as we build smarter interfaces and better digital experiences for everyday use. We're hiring now.

Written by

Erin Marchak

Erin Marchak

Sign up for our newsletter