- 6 minutes read

npm has a clever approach to dealing with transitive dependencies. Cutting a long story short: it does the exact opposite of what Maven does. npm trades a waste of memory for a lot of headaches. I've seen worse trade-offs!

I've written a small application to demonstrate the features. It uses three different versions of the same library simultaneously. At first glance, that's scary because it means you never get rid of security issues that have been fixed ages ago. But fear not - that's one of the reasons why the package.lock file has been invented. npm audit fix solves most of those old security issues.

Let's have a closer look at the topic.

Transitive dependencies

Let's assume we've written a library called "Deep Thought." The first version blurts out the answer to the question of life, the universe, and everything: 42.

As things go, our customer considered that boring. Version 2.0 is a much more sophisticated version of the original implementation. It prints, "21 is merely half the truth".

Later, marketing observes that the word "half" has a negative connotation. Time to publish version 3.0. Now it reads, "84 is twice the truth".

Before you ask: yes, I'm a bookworm and a nerd. But that's missing the point. The bottom line is that each version of the library introduces a breaking change.

Let's assume our Angular application was written a long time ago, so it's still using version 1.0 of the Deep Thought library. It's also using two other libraries depending on deep thoughts - but as things go, their authors published security updates, so they are using version 2.0 and 3.0, respectively.

Does that sound sick? Yes, it is. The thing is, reality often confronts us with such shit. We have to deal with it, one way or another.

Looking into the node_modules folder

It's time to have a look at the node_modules folder.

The node_modules folder is huge, so that I couldn't include the top layer with the screenshot. Just believe me: that's what you see when inspecting the node_modules folder and browsing for "beyondjava." Or rather, don't believe me. Just clone my repository and have a look yourself.

The folders "beyondjava-example-first-library" and "beyondjava-example-second-library" each contain a nested node_modules folder. These folders contain all the libraries requested by the package.json of the libraries. In our case, that's just one transitive dependency - it's our base library. Looking at the files, you'll see that all three library implementations are there, side-by-side, just a bit hidden.

package-lock.json

The package-lock.json of the Angular project shows the same:

"beyondjava-example-base-library": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/beyondjava-example-base-library/-/beyondjava-example-base-library-1.2.0.tgz", "integrity": "sha512-jsBlQrHCpX6ZjqBvbSqVu4t2UX6940QPyVJbTsQdh0Bp38/5JcXz64VjymQSAlMHrgKUHx1leJvGyFnJAA1xiA==" }, "beyondjava-example-first-library": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/beyondjava-example-first-library/-/beyondjava-example-first-library-1.0.2.tgz", "integrity": "sha512-3ZTI4tgSugMJN61XTdqQLbcn+zpa4CFMwOqKUevhQPsjAMZ8/7azr6SGcqGb5cplolCa79sNY45jzREs8TBiqQ==", "requires": { "beyondjava-example-base-library": "2.1.0" }, "dependencies": { "beyondjava-example-base-library": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/beyondjava-example-base-library/-/beyondjava-example-base-library-2.1.0.tgz", "integrity": "sha512-hl6WqZAoJvMzVRj6sOOfEjn/bmPpQIcDu+Ha5kbLl7yCQCwbeVAB33bHXE5a1+RLwXEH8WTN1rxCqilb7Avnhg==" } } }, "beyondjava-example-second-library": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/beyondjava-example-second-library/-/beyondjava-example-second-library-1.0.3.tgz", "integrity": "sha512-k69mM9FNw3wmkuAbLPSpWqrVuJ3njWwfKm8Ih0ng0QFWecOy9uuT3LWZUQR/DCz/VY45HBoDr1CexiCYmXn38Q==", "requires": { "beyondjava-example-base-library": "3.0.0" }, "dependencies": { "beyondjava-example-base-library": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/beyondjava-example-base-library/-/beyondjava-example-base-library-3.0.0.tgz", "integrity": "sha512-n1dSuCHF9QEvrXoCGXRnWS7X8ih/6vpnK5ajZNvpmihpHUozhD49TjFVYsxhKeCGR0SixdwmE25RM19zjbVzSw==" } } },

Of course, I've carefully designed the demo to guarantee sure these results. For instance, neither of the package.json files allows npm to use a newer version of the base library. I didn't allow npm to update the version numbers. There are no version numbers containing special characters like "^1.0.0", "~1.0.0" or ">1.0.0". I didn't want to see any surprises in the demo. Feel free to play around with the version numbers. When you set the version number of the base library in the Angular project to ">1.0.0", you should see a difference. Among other things, it results in a broken application.

What npm audit fix does

If you've ever wondered why npm audit reports so many security issues of libraries you've never heard of - you've just seen the answer. npm audit examines both the direct and the transitive dependencies. Its friendly sibling, npm audit fix, updates vulnerable libraries whenever possible, including transitive ones.

Putting the theory to test

It's one thing that npm stores transitive libraries in nested folders. It's an entirely different story what bundlers like Webpack do with these folders. So I've written a small Angular application importing three versions of the same library. The result is almost anticlimactic. It just works.

Granted, I was a bit lazy and implemented the three libraries as CommonJS libraries. That's frowned upon because CommonJS prevents efficient tree-shaking. But that's OK. I doubt it makes any difference in our example.

For the sake of completeness, here's the sourcecode:

import { Component } from '@angular/core'; import printDeepThoughts from 'beyondjava-example-base-library/index'; import { useDeepThoughts } from 'beyondjava-example-first-library/index'; import { useAndReturnDeepThoughts } from 'beyondjava-example-second-library/index'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { public version1: number; // 42 public version2: number; // 21 public version3: number; // 84 ngOnInit() { console.log("This text is printed from the base library 1.0.0 which has been imported directly:") this.version1 = printDeepThoughts(); console.log("This text is printed from the base library 2.0.0 which has been imported indirectly:") this.version2 = useDeepThoughts(); console.log("This text is printed from the base library 3.0.0 which has been imported indirectly by another library:") this.version3 = useAndReturnDeepThoughts(); } }

Using Webpack to publish libraries

It goes without saying that my example is a far cry from real-world scenarios. Most libraries I know don't have a nested node_modules folder. They've been concatenated and minified by a tool like Webpack before publishing.

That's both good and bad. It's good because the Webpack bundle contains everything the library needs. In particular, it includes the correct version of every transitive library. Correct meaning the library author has used that specific version during development.

But it's also bad because it means every transitive dependency is duplicated countless times, and npm audit fix can't simply update transitive dependencies to solve security issues.

Peer dependencies

Luckily, you can also declare dependencies as peer dependencies. That means these dependencies are not bundled with your library. They are just assumed to be there. It's up to the user to make sure there's a compatible version of the transitive libraries.

Wrapping it up

npm does a fairly good job managing transitive dependencies. That's a lot better than what our Java friends are used to. They often have to deal with hopeless dependency hells. Update any library, and everything breaks down. I know what I'm talking about. Me team ran into such a scenario this week. Usually, that doesn't happen with npm. Bundling multiple versions of the same library seems like a waste of memory. You can reduce the waste of memory using npm dedupe. However, if you're using Webpack, that's not necessary. Current versions of Webpack detect and remove the duplicates. Awesome!


Comments