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.

Reste der Treppe im Haus der Ziegel in Lerna, Griechenland
Quelle: Heinz Schmitz, Lizenz: CC BY-SA 2.5

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
Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.