Lerna Mono-Repos mit internen Abhängkeiten
Mono-Repos sind mittlerweile sehr beliebt, um einige miteinander in Zusammenhang stehende JavaScript-Bibliotheken in einem einzigen Repository zu vereinen. Herauszubekommen, wie genau Abhängigkeiten zwischen verschiedenen Paketen innerhalb dieses Repositories zu behandeln sind, war für mich etwas knifflig. Dieser Blog-Post beschreibt, wie ich es gelöst habe.
Weshalb Lerna?
Außerhalb der Arbeit habe ich den Mono-Repo-Manager lerna zuerst für esgettext verwendet. Esgettext ist in TypeScript geschrieben und besteht zur Zeit aus zwei Paketen @esgettext/esgettext-runtime
und @esgettext/esgettext-tools
. Letzteres hat eine Abhängigkeit zu ersterem, weil es mit @esgettext/esgettext-runtime
internationlisiert ist.
Die beiden Pakete parallel zu entwickeln, ist lästig, weil ein Paket erst in die NPM-Registry publiziert werden muss, bevor es vom anderen verwendet werden kann. Alternativ, kann man Git-URLs als Abhängigkeit in package.json
eintragen. Das muss jedoch vor der Publizierung manuell rückgängig gemacht werden.
Lerna löst dieses Problem auf elegante Art und Weise, Statt die interne Abhängigkeit herunterzuladen, wird einfach im Verzeichnis node_modules
des Unterpakets ein symbolischer Link erzeugt. Das reicht allerdings nicht für TypeScript und Jest, die weitere kleine Konfigurationsanpassungen erfordern.
Ein Mono-Repo mit lerna erzeugen
Statt einfach den Code von esgettext
anzuschauen, erzeugen wir lieber ein minimalistisches Mono-Repo mit Lerna von Grund auf.
Wer sich mit lerna, Jest und TypeScript bereits gut auskennt, kann die manuellen Schritte weiter unten auch einfach überspringen und gleich zu den aktuellen Stand klonen springen.
Um die einzelnen Schritte im Detail zu verstehen, ist es jedoch besser, den folgenden Instruktionen zu folgen. Zuerst erzeugen wir ein leeres Repository und initialisieren die Struktur für lerna
.
$ mkdir lerna-deps
$ git init lerna-deps
Initialized empty Git repository in /path/to/lerna-deps/.git/
$ cd lerna-deps
$ npm init -y
Wrote to ...
...
$ npm add --save-dev lerna
...
$ npx lerna init
lerna notice cli v3.22.1
lerna info Updating package.json
lerna info Creating lerna.json
lerna info Creating packages directory
lerna success Initialized Lerna files
Ziel ist es, eine Bibliothek zu schreiben, die eine Funktion fortyTwo()
beinhaltet, welche die Zahl 42 zurückliefert. Ein Kommandozeilen-Tool fortyTwo
soll diese Bibliothek verwenden, um die Zahl 42 auf der Kommandozeile auszugeben.
Ein Lerna-Paket erzeugen
Unter-Pakete werden mit dem Kommando lerna create
im Verzeichnis packages
erzeugt. Starten wir mit der Bibliothek:
$ cd lerna-deps
$ npx lerna create @forty-two/forty-two-runtime
... hit ENTER all the time
Is this OK? (yes)
lerna success create New package @forty-two/forty-two-runtime created at ./packages/forty-two-runtime
Das Kommando hat einige Dateien und Verzeichnisse erzeugt, die nicht benötigt werden und deshalb gelöscht werden können:
$ cd lerna-deps/packages/forty-two-runtime
$ rm -r README.md __tests__ lib
Die einzige Datei, die übrig bleiben sollte ist packages/forty-two-runtime/package.json
.
Wir haben ein sogenanntes "Scoped" Paket @forty-two/forty-two-runtime
statt einfach forty-two-runtime
erzeugt. Dies wird sehr häufig mit Lerna-Mono-Repos gemacht.
Es müssen auch noch zwei Skripte zur obersten (!) Ebene von package.json
zugefügt werden:
...
"scripts": {
"bootstrap": "lerna bootstrap",
"test": "lerna run test --stream"
},
...
TypeScript verwenden
Es soll TypeScript statt Standard-JavaScript verwendet werden. Dazu muss zunächst eine Datei lerna-deps/tsconfig.json
mit der paket-übergreifenden TypeScript-Konfiguration erzeugt werden:
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitUseStrict": true,
"removeComments": true,
"declaration": true,
"target": "es5",
"lib": ["es2015", "dom"],
"module": "commonjs",
"sourceMap": true,
"typeRoots": ["node_modules/@types"],
"esModuleInterop": true,
"moduleResolution": "node"
},
"exclude": [
"node_modules",
"**/*.spec..ts"
]
}
Im Detail muss das natürlich an die eigenen Bedürfnisse angepasst werden.
Auf jeden Fall muss dem Projekt aber die Abhängigkeit typescript
zugefügt werden:
$ cd lerna-deps
$ npm install --save-dev typescript
...
$
Jest einbinden
Zum Testen soll Jest zum Einsatz kommen:
$ cd lerna-deps
$ npm install --save-dev jest ts-jest
...
$
Weiterhin wird das Paket ts-jest
benötigt, um Jest mit TypeScript nutzen zu können. Dazu muss ein Schlüssel "jest" auf der obersten Ebenen von lerna-deps/packages/forty-two-runtime/package.json
eingefügt werden:
...
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
...
Jetzt müssen wir das Mono-Repo mit dem Kommando lerna bootstrap
bootstrappen. Weil wir ein Skript dafür in package.json
hinterlegt haben, können wir dies so bewerkstelligen:
$ cd lerna-deps
$ npm run bootstrap
> lerna-deps@1.0.0 bootstrap /path/to/javascript/lerna-deps
> lerna bootstrap
lerna notice cli v3.22.1
lerna info Bootstrapping 1 package
lerna info Symlinking packages and binaries
lerna success Bootstrapped 1 package
Am besten führt man den Bootstrap-Schritte nach jeder Strukturänderng am Mono-Repo durch.
Der erste Test
Für den ersten Test muss das Skript test
in lerna-deps/packages/forty-two-runtime/package.json
folgendermaßen geändert werden:
...
"script": {
"test": "jest"
}
Als nächstes muss das Verzeichnis lerna-deps/packages/forty-two-runtime/src
erzeugt werden und in diesem Verzeichnis eine Test-Datei lerna-deps/packages/forty-two-runtime/src/forty-two.spec.ts
:
import { FortyTwo } from './forty-two';
describe('forty-twp', () => {
it('should produce forty-two', () => {
expect(FortyTwo.magic()).toEqual(42);
});
});
Im Wurzelverzeichnis des Projekts lassen wir jetzt den Test laufen:
$ cd lerna-deps
$ npm test
> lerna-deps@1.0.0 test /path/to/lerna-deps
> lerna run test --stream
...
npm ERR! Test failed. See above for more details.
$
Wie zu erwarten war, schlägt der Test fehl, weil die Implementierung der Funktion noch fehlt. Das lässt sich mit einer Datei lerna-deps/packages/forty-two-runtime/src/forty-two.ts
beheben:
export class FortyTwo {
public static magic() {
return 42;
}
}
Ein erneuter Start der Test-Suite ist jetzt erfolgreich:
$ cd lerna-deps
$ npm test
> lerna-deps@1.0.0 test /path/to/lerna-deps
> lerna run test --stream
...
lerna success - @forty-two/forty-two-runtime
$
Sollte das wider Erwarten nicht funktionieren, muss vermutlich im Wurzelverzeichnis npm run bootstrap
ausgeführt werden.
Den aktuellen Stand klonen
Der aktuelle Stand lässt sich auch mit folgendem Kommando auf die lokale Maschine kopieren:
$ git clone https://github.com/gflohr/lerna-deps.git
...
$ cd lerna-deps
$ git checkout starter
$ npm install
...
$ npm run bootstrap
...
$ npm test
...
lerna success - @forty-two/forty-two-runtime
$
Das Tag "starter" zeigt auf den Stand entsprechend dem aktuellen Schritt im Repository.
Index-Datei erzeugen
Normalerweise sollte der Einstiegspunkt einer TypeScript-Datei eine Datei index.ts
sein. Dazu erzeugt man packages/forty-two-runtime/src/index.ts
:
export * from './forty-two';
Das "Tools"-Paket erzeugen
Als nächstes wird die Kommandozeilen-Schnittstelle zur Laufzeit-Bibliothek als weiteres Unterpaket erzeugt, wieder mit lerna create
:
$ cd lerna-deps
$ npx lerna create @forty-two/forty-two-tools
Wieder sollte innerhalb des Verzeichnisses lerna-deps/packages/forty-two-tools
alles außer package.json
gelöscht werden:
$ cd lerna-deps/packages/forty-two-tools
$ rm -r README.md __tests__ lib
$ mkdir src
Natürlich sollte auch die Kommandozeilen-Schnittstelle getestet werden. Dazu muss das Test-Skript in lerna-deps/packages/forty-two-tools/package.json
angepasst werden:
"script": {
"test": "jest
}
Und in der gleichen Datei muss Jest zur Verwendung mit TypeScript konfiguriert werden:
...
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
...
Unit-Test für das Kommandozeilen-Tool
Als nächstes soll ein Test im Verzeichnis lerna-deps/packages/forty-two-tools/src/
geschrieben werden:
$ mkdir lerna-deps/packages/forty-two-tools/src
Der Test lerna-deps/packages/forty-two-tools/src/forty-two-cli.spec.ts
sieht folgendermaßen aus:
import { FortyTwoCLI } from './forty-two-cli';
describe('forty-two cli', () => {
it('should return 42 from the CLI wrapper', () => {
expect(FortyTwoCLI.magic()).toBe(42);
});
});
Dieser Test wird fehlschlagen, weil die Implementierung lerna-deps/packages/forty-two-tools/src/forty-two-cli.ts
noch fehlt:
import { FortyTwo } from '@forty-two/forty-two-runtime';
export class FortyTwoCLI {
public static magic() {
return FortyTwo.magic();
}
}
Aber npm test
im Wurzelverzeichnis schlägt noch immer fehl, und zwar mit folgender Fehlermeldung:
src/forty-two-cli.spec.ts:1:29 - error TS2307: Cannot find module './forty-two-cli' or its corresponding type declarations.
Interne Abhängkeiten mit TypeScript auflösen
Der erste Lösungsschritt besteht darin, TypeScript so zu konfigurieren, dass die interne Abhängigkeit aufgelöst werden kann. Dazu wird eine Datei lerna-deps/packages/forty-two-tools/tconfig.json
mit diesem Inhalt erzeugt:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@forty-two/forty-two-runtime": ["../forty-two-runtime/src"]
}
},
"includes": ["./src"]
Wichtig sind hier die beiden Compiler-Optionen "baseUrl" und "paths".
Das Objekt "paths"
bildet Paketnamen auf eine Suchliste im Dateisystem ab. Damit "paths" verwendet werden kann, muss "baseUrl" gesetzt sein!
Interne Abhängigkeiten für Jest auflösen.
Damit TypeScript die interne Abhängigkeit auflösen kann, reicht die Anpassung der tsconfig.json
. Jest benötigt jedoch zusätzliche Konfiguration. Dazu muss in lerna-deps/packages/forty-two-tools/package.json
das Object "jest" wie folgt geändert werden.
...
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"moduleNameMapper": {
"^@forty-two/forty-two-runtime$": "<rootDir>/../../forty-two-runtime/src"
},
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
...
Hinzugekommen ist moduleNameMapper
. Es ist ein Objekt, das Modulnamen als reguläre Ausdrücke auf Pfade im Dateisystem abbildet.
Ein erneuter Lauf von npm test
im Wurzelverzeichnis wird jetzt erfolgreich sein.
Die Abhängigkeit mit Lerna hinzufügen
Ein Blick in lerna-deps/packages/forty-two-tools/package.json
offenbart, dass im Moment keinerlei Abhängigkeit eingetragen ist. Der Versuch, @forty-two/forty-two-tools
von einer NPM-Registry zu installieren würde deshalb fehlschlagen.
Der Fehler kann mit lerna add
behoben werden:
$ cd lerna-deps
$ npx lerna add @forty-two/forty-two-runtime
Was hier passiert, ist dass die Abhängigkeit @forty-two/forty-two-runtime
der Datei package.json
von @forty-two/forty-two-tools
zugefügt wird, und das Verzeichnis node_modules
mit einem symbolischen Link zur Laufzeit- Bibliothek gefüllt wird, so dass npm
oder yarn
nicht versuchen, das Paket von der NPM-Registry herunterzuladen:
$ cd lerna-deps
$ ls -l packages/forty-two-tools/node_modules/@forty-two
total 0
lrwxr-xr-x 1 guido staff 26 Sep 11 09:36 forty-two-runtime -> ../../../forty-two-runtime
Der symbolische Link existiert nur in der lokalen Entwcilungsumgebung. Wer das Paket von einer NPM-Registry wie https://npmjs.com lädt, wird die Quellen ganz normal herunterladen.
Die vollständigen Quelltexte des Beispiels stehen auf github zum Download zur Verfügung:
$ git clone git@github.com:gflohr/lerna-deps
Oder, wenn man bereits den Zwischenstand ausgecheckt hatte:
$ cd lerna-deps
$ git checkout master
$ git pull
Was noch fehlt
Diese Anleitung soll weder ein TypeScript- noch ein erschöpfendes lerna
-Tutorial sein. So fehlt beispielsweise der Build-Schritt vollständig. Außerdem enthält das Paket forty-two-tools
kein echtes Kommandozeilen-Skript.
Wer sich für ein vollständiges Beispiel interessiert, kann einen Blick auf das Mono-Repo https://github.com/gflohr/esgettext werfen, das die gleichen Methoden, die hier beschrieben sind, verwendet. Des weiteren lässt sich dort auch sehen, wie der Laufzeit-Part sowohl im Browser als auch auf dem Server mit NodeJS ausgeführt werden kann.
Leave a comment