Stolpersteine und Lösungen beim Umstieg von einem pnpm-Monorepo auf Nub
٢٧ يوليو ٢٠٢٦
0
Computing/SoftwareComments (0)
Log in to leave a comment
No posts yet
Log in to leave a comment
No posts yet
Das Node.js-Ökosystem ist erschöpft. Eine Build-Umgebung, die schichtweise auf tsx, dotenv-cli, nvm und pnpm aufgebaut ist, ist schwerfällig, und bei jedem kleinen Update geraten die Skripte durcheinander. Als Nub, ein in Rust geschriebenes All-in-One-Toolkit, auf den Plan trat und versprach, dieses Paket-Chaos in einer einzigen Binärdatei zu bündeln, war ich ehrlich gesagt erfreut, aber auch skeptisch.
Der tatsächliche Umstieg eines gewachsenen pnpm-workspace-Monorepos auf Nub erwies sich als ziemlich attraktiv. Allerdings lief natürlich nichts völlig reibungslos. Von Abhängigkeitskonflikten über eine 24-Stunden-Sicherheits-Sperrfrist bis hin zu Problemen bei der CI-Cache-Trennung – es tauchten Hürden auf, die einen in der Praxis direkt treffen.
Nubs Paket-Engine aube erkennt das bestehende pnpm-workspace.yaml sowie die workspaces in der package.json auf Anhieb. Wie von der Dokumentation versprochen – voll kompatibel mit dem Schema von pnpm-lock.yaml v9 bis auf Byte-Ebene –, nutzt sie standardmäßig die bestehende virtuelle Store-Struktur (node_modules/.store/).
Das Problem sind ältere Tools, die stiltschweigend eine flache (hoisted) node_modules-Struktur voraussetzen. Führt man sie direkt nach der Migration aus, stürzen sie mit einem MODULE_NOT_FOUND-Fehler ab. Um den Dienst vorerst am Laufen zu halten, sollte man mit der Option --node-linker hoisted starten, um den Paketbaum flachzuklopfen.
Bei einer Warm-Installation mit angewendetem Cache benötigt der Standard-GVS-Modus 346 ms. Der Hoisted-Modus ist mit 1461 ms mehr als viermal langsamer, aber immer noch mehr als doppelt so schnell wie pnpm v10+ mit 3453 ms. Für den Anfang fährt man am entspanntesten, wenn man sich die Kompatibilität über den Hoisted-Modus sichert und erst nach dem Entfernen der Abhängigkeiten zu veralteten Tools auf den Standard-GVS-Modus wechselt.
`bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> [1/4] Nub-Binärdatei überprüfen"
if ! command -v nub &> /dev/null; then
echo "Error: Nub ist nicht installiert. Bitte zuerst 'npm i -g @nubjs/nub' ausführen."
exit 1
fi
echo "==> [2/4] pnpm-lock.yaml Schema-Check"
if [ -f "pnpm-lock.yaml" ]; then
nub pm use nub
fi
echo "==> [3/4] Ausführungs-Engine in package.json angeben"
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg.devEngines = pkg.devEngines || {};
pkg.devEngines.packageManager = {
name: "nub",
version: "^0.4.0",
onFail: "warn"
};
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
echo "==> [4/4] Lockfile generieren"
nub install --frozen-lockfile=false
`
Wenn man pnpm-lock.yaml mit diesem Skript in nub.lock konvertiert, spart man sich pro Woche mehr als 2 Stunden Zeit, die vorher für das Reparieren von Skripten draufgingen.
Nub hat ziemlich strenge Sicherheitsrichtlinien. Lifecycle-Skripte werden standardmäßig blockiert, und ohne eine npm Provenance-Signatur wird ein ERR_NUB_TRUST_DOWNGRADE geworfen. Am meisten überrascht jedoch die Option minimumReleaseAge. Paketversionen, die vor weniger als 24 Stunden veröffentlicht wurden, gelten als ausstehend in der Sicherheitsprüfung und werden an der Installation gehindert.
Im Alltag ist das ein hervorragender Schutzschild, aber wenn eine Zero-Day-Schwachstelle auftaucht und man eine vor einer Stunde erschienene Patch-Version sofort deployen muss, wird es zur Mauer der Verzweiflung. In diesem Fall muss man die erlaubten Build-Skript-Pakete in der Root-package.json explizit angeben und mit Befehlen freischalten.
`json
{
"name": "@org/monorepo-root",
"private": true,
"allowBuilds": {
"esbuild": true,
"sharp": true,
"@fast-cve/patch-pkg": true
}
}
`
Der Ablauf zum manuellen Einspielen eines blockierten Hotfix-Pakets sieht wie folgt aus:
`bash
nub add --allow-build=@fast-cve/patch-pkg @fast-cve/patch-pkg@1.0.1-hotfix
nub approve-builds
`
Durch Ausführen von nub approve-builds wird die Sperrliste aufgehoben und der Build sofort fortgesetzt. Pakete, die in der OSV-Datenbank als Schadcode (MAL-*) registriert sind, lassen sich selbst mit diesem Befehl nicht erzwingen – in solchen Fällen muss man nach einer höheren Ausweichversion suchen.
Um die CI/CD-Geschwindigkeit zu maximieren, muss man die Cache-Pfade von Nub kennen. Die Node-Binärdatei landet in ~/.cache/nub/node/<version>/, während der Paket-CAS (Content-Addressable Store) auf ~/.cache/nub/ und node_modules/.store/ aufgeteilt wird.
In Docker Multi-Stage-Builds erzielt man den besten Caching-Effekt, indem man den Cache-Mount von BuildKit nutzt, um die Installationsschicht vollständig zu isolieren.
`dockerfile
FROM ghcr.io/nubjs/nub:latest AS base
WORKDIR /app
FROM base AS dependencies
COPY package.json nub.lock pnpm-workspace.yaml ./
COPY packages/core/package.json ./packages/core/
COPY packages/api/package.json ./packages/api/
RUN --mount=type=cache,target=/root/.cache/nub
nub ci --prefer-offline
FROM dependencies AS builder
COPY . .
RUN nub run build --filter=@org/api
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/packages/api/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
`
In GitHub Actions bindet man anstelle von actions/setup-node einfach nubjs/setup-nub@v0 ein.
`yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nubjs/setup-nub@v0
with:
cache: true
- run: nub ci
- run: nub -r run build
- run: nub -r run test
`
Die Geschwindigkeit von nub ci in Kombination mit setup-nub und Cache liegt bei rund 346 ms. Im Vergleich zu bisherigem pnpm (3453 ms) verkürzt sich die Zeit für die Paketinstallation in der Pipeline drastisch, wodurch sich die gesamte CI-Build-Zeit halbiert. Wenn man am Monatsende auf die Cloud-Infrastruktur-Abrechnung schaut, hebt das definitiv die Stimmung.
Nub bringt einen integrierten Speicher-Transpiler auf oxc-Basis mit, sodass TypeScript-Code direkt ohne tsx oder ts-node ausgeführt werden kann. .env-Dateien werden ebenfalls automatisch eingelesen. Da der Overhead beim Bootstrappen des Node.js-Prozesses – der bei pnpm run (442,7 ms) jedes Mal auftrat – entfällt, sinkt die Skript-Ausführungszeit mit nub run auf extrem schnelle 14,7 ms. Der Unterschied ist spürbar.
Um eine Fragmentierung der Node.js-Versionen im Team zu verhindern, hinterlegt man einfach eine .node-version im Root-Verzeichnis.
`bash
echo "22.15.0" > .node-version
nub src/index.ts
`
Ab Node 22.15.0 verschwindet die Cold-Start-Verzögerung dank synchronem module.registerHooks() vollständig. Wenn neue Teammitglieder hinzustoßen, ist das Einrichten eines Automatisierungsskripts der sauberste Weg, um die Umgebung ohne lange Erklärungen auf Anhieb abzugleichen.
`bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Starte Setup der Entwicklungsumgebung"
if ! command -v nub &> /dev/null; then
if command -v brew &> /dev/null; then
brew install nubjs/tap/nub
else
npm install -g --ignore-scripts=false @nubjs/nub
fi
fi
nub pm shim
nub node install
nub install
if [ ! -f ".env.local" ] && [ -f ".env.example" ]; then
cp .env.example .env.local
fi
echo "==> Setup abgeschlossen. Starten Sie mit 'nub run dev'."
`
Wenn man einmal nub pm shim ausgeführt hat, werden selbst gewohnheitsmäßig eingegebene Befehle wie pnpm install oder npm run von Nub abgefangen und verarbeitet. Neben der Reduzierung des CLI-Execution-Overheads von pnpm exec (191 ms) auf nubx (11 ms) spart man sich pro Woche mindestens 2 Stunden Debugging-Zeit für Sätze wie "Auf meinem Rechner funktioniert es aber".