Dependency resolution and installation (provision) of mixed CLJS (maven) and JavaScript (npm) projects.
Lets consider a relatively simplified example in the following diagram:
+--------------+
|react@16.13.0 +<--------------------------+
|from npm | |
+-------+------+ |
^ |
| |depends on via package.json peerDependencies
|depends on via deps.cljs |
| |
| |
+-------+-------+ +-------+------------+
|reagent@0.10.0 | |framer+motion@1.8.3 |
|from maven | |from npm |
| | | |
+-------+-------+ +--------+-----------+
^ ^
| |
|depends on via project.clj |depends on via deps.cljs
| |
| |
| +--------------+ |
| |My Application| |
+--------+ +-----------+
| |
+--------------+
- 'My Application' declares two dependencies:
reagent@0.10.0via its ownmy-app/project.clj/:dependenciesframer-motion@1.8.3via its ownmy-app/src/deps.cljs/:npm-depswhich is equivalent tomy-app/package.json/:dependencies
reagent@0.10.0declares many dependencies, but for our example only one we will mention:react@16.13.0via its ownreagent/src/deps.cljswhich is found on the class path byshadow-cljsat build time (notlein-shadow, or other tools) and injected vianpm install --save react@16.13.0intomy-app/package.json
framer-motion@1.8.3declares many dependencies, but for our example only one we will mention:react@^16.8via its ownframer-motion/package.json/:peerDependencies
Some important things to take away from this diagram include:
- To successfully build 'My Application' there are TWO independent, yet merged into one graph, dependency systems. Maven and NPM.
- A dependency can be required via multiple paths that need to be satisfied via both dependency systems; e.g.
react@16.13.0vsreact@^16.8 - Transitive NPM dependencies can be via NPM
package.jsonfiles (NPM dependencies of NPM dependencies), or viashadow-cljsdeps.cljsfiles (NPM dependencies of CLJS dependencies).
- 'Vanilla
shadow-cljs'. Justshadow-cljsandnpm(asnpm, oryarnwhich we don't consider in this discussion, is a required dependency ofshadow-cljs). shadow-cljs,npmandleinBUT NOTlein-shadow; probably via a lot of lein alaises andlein-shellout to theshadow-cljsCLI.shadow-cljs,npm,leinandlein-shadow; usesshadow-cljsas a JAR library, not theshadow-cljsCLI.- Option 2. or 3. with the addition of a
package-lock.jsonfile
What dependency resolution use cases are actually different between these stacks ? Turns out, not a lot. Only Use Case 2. has any variation.
Only one option. Maven dependency in project.clj/:dependencies (or deps.edn, or shadow-cljs.edn).
There are two options, which are very similar:
- NPM dependency in
package.json/:dependencies, managed directly withnpmandshadow-cljs('Vanillashadow-cljs') - NPM dependency in
src/deps.cljs/:npm-deps, managed vialein-shadowwhich will write it out topackage.json/:dependencies.
Technically, if we include tooling other than shadow-cljs there is the option of repackaged JavaScript in Maven dependencies via CLJSJS but we rule that out of the discussion as it has too many disadvantages.
Only one option. NPM dependency in deps.cljs in the CLJS dependency project, handled by shadow-cljs and npm. 'Vanilla shadow-cljs' searches for deps.cljs files on the classpath, does conflict resolution with the current project's package.json then if it is required executes npm install dep@ver --save....
Only one option. Maven dependency in project.clj in the CLJS dependency project, handled by lein.
Only one option. NPM dependency in package.json in the JS dependency project, handled by npm.
| Feature | 1. shadow-cljs & npm |
2. lein-shadow & npm |
3. lein-shadow & npm & package-lock.json |
|---|---|---|---|
| A. NPM interactions | ❌ Manual | ✔️ Automated | ✔️ Automated |
| B. NPM integration | ✔️ Simple | ❌ Complex | ❌ Complex |
| C. NPM peerDependencies | ❌ Manual 1 2 | ✔️ Semi-Automated | ✔️ Semi-Automated |
| D. NPM locked deps | ✔️ | ❌ | ✔️ |
E. package-lock.json in Git |
❌ Manual Commits | ✔️ Nothing to Commit | ❌ Manual Commits |
| F. Git tag-based versioning | ❓ TBA | ✔️ lein-git-inject |
✔️ lein-git-inject |
| G. Dynamic shadow-cljs.edn config | ✔️ | ✔️ |
NPM interactions include:
npm installornpm ci; i.e. creating anode_modulesfolder frompackage.jsonandpackage-lock.jsonnpm install name@ver --save{,-dev}; i.e. adding a dependency
NPM integration points include:
package.jsonandpackage-lock.jsonfilesnpmcli toolshadow-cljswhich, in any of the options, readspackage.jsonto compare it withdeps.cljsfiles on the classpath (transitive npm dependencies, e.g.reactfromreagent) then runsnpm install react@ver --save.lein-shadowwhich, if used, readspackage.jsonand runsnpm installand/ornpm install dep@ver --save{,-dev}.
NPM 5 removed support for automatic installation of 'peerDependencies'. This includes react in
some scenarios.
i.e. 'package-lock.json'
Solves the problem that we can specify exact versions for our own dependencies, but dependencies of our dependencies (transitive dependencies) may be pulled in via version ranges that auto-upgrade without our knowledge or intent.
If we track package-lock.json in Git then we need to regularly commit the changes manually at times
that it should be changed such as adding or deliberately upgrading a dependency. This requires
developers to:
- recognise correctly when
package-lock.jsonshould be changed, and when it should not. - commit and push those changes.
- avoid merge conflicts of
package-lock.jsonsuch as by not maintain multiple branches ofpackage-lock.jsonchanges.
Accurate versioning is critical to operational and technical support use cases; e.g. simply being able to answer the question, what code built this misbehaving app in production?
To reduce errors since the Figwheel days we have programatically generated parts of the compiler
configuration in project.clj; e.g. dir/file paths.
XXX
Where we use npm install to mean 'install all the things in package.json', we would probably be
better served by npm ci which has some key differences.
Currently peer dependencies work because shadow-cljs.edn and/or lein-shadow run multiple
npm install dep@ver --save{,-dev} commands when a package.json file does NOT exist. In this case,
the dependency (e.g. react) is always installed.
If a package.json file already exists, such as the case needs to be when using package-lock.json
then in some cases peer dependencies are not installed with npm install because of it being removed
in NPM 5.