From 073f2cf9caeefb3cd6427737b0446dc2b480b27f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 19 Jun 2026 09:46:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(packaging):=20inline=20@arboretum/shared=20?= =?UTF-8?q?dans=20dist/=20=E2=80=94=20tarball=20autonome=20(abandon=20de?= =?UTF-8?q?=20bundleDependencies)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundleDependencies d'un paquet *workspace* est instable selon l'environnement npm (mode -w, exécution en root sur un runner CI) : npm voyait shared comme un lien workspace et n'embarquait AUCUN fichier ("bundled files: 0"), publiant un tarball cassé — `Cannot find module '@arboretum/shared'` au démarrage chez le consommateur. C'est ce que détectait (à juste titre) le job pack-smoke. Nouveau mécanisme, identique sur tout environnement : scripts/inline-shared.mjs (hook prepack) copie le JS compilé de shared dans dist/_shared/ et réécrit l'import bare '@arboretum/shared' des .js du serveur vers ce chemin relatif. shared passe en devDependency (résolu en dev via le symlink workspace, jamais exigé du consommateur). Plus de node_modules embarqué ni de symlink dans le tarball. ci.yml (pack-smoke) packe désormais en -w (comme release.yml) et asserte l'autonomie (dist/_shared présent + zéro import bare) avant le boot smoke. Validé de bout en bout sur checkout propre : npm ci + build + pack -w + install dans projet vierge + boot -> HTTP 401, et 263 tests verts. --- .github/workflows/ci.yml | 29 +++++---- package-lock.json | 5 +- packages/server/package.json | 7 +-- packages/server/scripts/inline-shared.mjs | 71 +++++++++++++++++++++++ packages/server/scripts/vendor-shared.mjs | 43 -------------- 5 files changed, 88 insertions(+), 67 deletions(-) create mode 100644 packages/server/scripts/inline-shared.mjs delete mode 100644 packages/server/scripts/vendor-shared.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e74c1cd..29a31e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,27 +41,26 @@ jobs: cache: npm - run: npm ci - run: npm run build - # @arboretum/shared (dépendance runtime non publiée) est embarquée dans le tarball - # via bundleDependencies (matérialisée au prepack). On packe donc UN SEUL tarball - # et on l'installe seul, comme un vrai consommateur depuis le registre. + # @arboretum/shared (paquet workspace NON publié) est INLINÉ dans dist/_shared au prepack + # (scripts/inline-shared.mjs) : le tarball est 100 % autonome — aucun node_modules embarqué, + # aucune bundleDependency, aucun symlink. On packe en mode workspace (-w), EXACTEMENT comme + # le fait « npm publish » dans release.yml, puis on l'installe seul comme un vrai consommateur. - name: Pack tarball run: | rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs - # @arboretum/shared (dépendance runtime non publiée) est embarquée via bundleDependencies, - # matérialisée par le hook prepack (copy-web + vendor-shared + copy-meta) comme VRAI dossier - # dans packages/server/node_modules/@arboretum/shared. - # On packe depuis le CONTEXTE DU PACKAGE (cd), PAS en mode workspace (-w) : « npm pack -w » - # résout @arboretum/shared via le symlink workspace hoisté de la racine et n'embarque donc - # PAS le dossier vendored local. « cd packages/server && npm pack » lance le lifecycle pack - # complet et reproduit fidèlement « npm publish » (qui, lui, embarque correctement). - ( cd packages/server && npm pack --pack-destination /tmp/tarballs ) + npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs ls -l /tmp/tarballs - - name: Assert @arboretum/shared is bundled in the tarball + - name: Assert the package is self-contained (@arboretum/shared inlined) run: | tgz=$(ls /tmp/tarballs/*.tgz) - tar -tzf "$tgz" | grep -q 'node_modules/@arboretum/shared/dist/index.js' \ - || { echo "ERREUR: @arboretum/shared non embarqué dans $tgz"; exit 1; } - echo "OK: bundleDependency @arboretum/shared présente dans $tgz" + rm -rf /tmp/inspect && mkdir -p /tmp/inspect && tar -xzf "$tgz" -C /tmp/inspect + test -f /tmp/inspect/package/dist/_shared/index.js \ + || { echo "ERREUR: dist/_shared/index.js absent de $tgz — inline-shared n'a pas tourné ?"; exit 1; } + if grep -rq '@arboretum/shared' /tmp/inspect/package/dist; then + echo "ERREUR: import bare '@arboretum/shared' encore présent dans le JS publié" + grep -rn '@arboretum/shared' /tmp/inspect/package/dist; exit 1 + fi + echo "OK: paquet autonome — shared inliné dans dist/_shared, aucun import externe" - name: Install tarball in an empty project run: | mkdir /tmp/smoke diff --git a/package-lock.json b/package-lock.json index cc8328c..984f4d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4841,12 +4841,8 @@ "packages/server": { "name": "@johanleroy/git-arboretum", "version": "1.4.0", - "bundleDependencies": [ - "@arboretum/shared" - ], "license": "MIT", "dependencies": { - "@arboretum/shared": "0.1.0", "@fastify/cookie": "^11.0.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.0.0", @@ -4859,6 +4855,7 @@ "arboretum": "dist/index.js" }, "devDependencies": { + "@arboretum/shared": "0.1.0", "@types/web-push": "^3.6.4", "@types/ws": "^8.5.0" }, diff --git a/packages/server/package.json b/packages/server/package.json index 9c04a40..81822b0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -48,14 +48,10 @@ "scripts": { "build": "tsc -b", "dev": "tsc -b --watch & node --watch dist/index.js", - "prepack": "node scripts/copy-web.mjs && node scripts/vendor-shared.mjs && node scripts/copy-meta.mjs", + "prepack": "node scripts/copy-web.mjs && node scripts/inline-shared.mjs && node scripts/copy-meta.mjs", "test": "vitest run" }, - "bundleDependencies": [ - "@arboretum/shared" - ], "dependencies": { - "@arboretum/shared": "0.1.0", "@fastify/cookie": "^11.0.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.0.0", @@ -65,6 +61,7 @@ "web-push": "^3.6.7" }, "devDependencies": { + "@arboretum/shared": "0.1.0", "@types/web-push": "^3.6.4", "@types/ws": "^8.5.0" } diff --git a/packages/server/scripts/inline-shared.mjs b/packages/server/scripts/inline-shared.mjs new file mode 100644 index 0000000..37c2a39 --- /dev/null +++ b/packages/server/scripts/inline-shared.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Inline @arboretum/shared (paquet workspace NON publié) directement dans le dist du serveur, +// pour que le tarball npm soit 100 % autonome. Branché sur le hook "prepack". +// +// Pourquoi PAS bundleDependencies : embarquer une dépendance qui est aussi un *workspace* via +// bundleDependencies est instable selon l'environnement npm (mode -w, exécution en root sur un +// runner CI, version d'arborist) — npm voit le nœud comme un lien workspace et n'embarque parfois +// AUCUN fichier ("bundled files: 0"), produisant un paquet cassé chez le consommateur. On élimine +// donc toute magie de bundling : on copie le JS compilé de shared dans dist/_shared et on réécrit +// l'import bare '@arboretum/shared' du serveur vers ce chemin relatif. Zéro node_modules embarqué, +// zéro symlink, résultat identique partout. +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const serverDist = join(serverDir, 'dist'); +const sharedDist = join(serverDir, '..', 'shared', 'dist'); +const inlineDir = join(serverDist, '_shared'); + +for (const [label, p] of [['dist serveur', serverDist], ['dist shared', sharedDist]]) { + if (!existsSync(p)) { + console.error(`inline-shared: ${label} introuvable (${p}) — lance "npm run build" avant le pack.`); + process.exit(1); + } +} + +// 1) Copier le JS compilé de shared dans dist/_shared (uniquement *.js : seul le runtime compte ; +// les .d.ts/.map ne sont de toute façon pas publiés via le glob `files`). index.js réexporte +// ./protocol.js et ./api.js en relatif → la copie complète préserve la résolution interne. +rmSync(inlineDir, { recursive: true, force: true }); +mkdirSync(inlineDir, { recursive: true }); +let copied = 0; +for (const name of readdirSync(sharedDist)) { + if (name.endsWith('.js')) { + cpSync(join(sharedDist, name), join(inlineDir, name)); + copied++; + } +} +if (copied === 0) { + console.error(`inline-shared: aucun .js dans ${sharedDist} — shared n'est pas compilé.`); + process.exit(1); +} + +// 2) Réécrire l'import bare '@arboretum/shared' de chaque .js du serveur vers le chemin relatif +// (POSIX) pointant sur dist/_shared/index.js, calculé par fichier (profondeur variable). +const walk = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = join(dir, e.name); + if (e.isDirectory()) return p === inlineDir ? [] : walk(p); // ne pas se réécrire soi-même + return e.name.endsWith('.js') ? [p] : []; + }); + +let rewritten = 0; +for (const file of walk(serverDist)) { + const src = readFileSync(file, 'utf8'); + if (!src.includes('@arboretum/shared')) continue; + let rel = relative(dirname(file), join(inlineDir, 'index.js')).split(sep).join('/'); + if (!rel.startsWith('.')) rel = `./${rel}`; + const out = src.replaceAll(`'@arboretum/shared'`, `'${rel}'`).replaceAll(`"@arboretum/shared"`, `"${rel}"`); + if (out !== src) { + writeFileSync(file, out); + rewritten++; + } +} +if (rewritten === 0) { + console.error(`inline-shared: aucun import '@arboretum/shared' réécrit dans ${serverDist} — build manquant ?`); + process.exit(1); +} + +console.log(`inline-shared: ${copied} fichier(s) shared -> dist/_shared, import réécrit dans ${rewritten} fichier(s) serveur`); diff --git a/packages/server/scripts/vendor-shared.mjs b/packages/server/scripts/vendor-shared.mjs deleted file mode 100644 index 2fe2a92..0000000 --- a/packages/server/scripts/vendor-shared.mjs +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -// Embarque @arboretum/shared (dépendance runtime non publiée) DANS le tarball npm. -// Branché sur le hook "prepack", aux côtés de bundleDependencies dans package.json. -// -// Pourquoi un vrai dossier et pas le symlink workspace : sous npm workspaces, shared -// est seulement symlinké dans le node_modules racine ; `npm pack` n'embarque une -// bundleDependency que si elle existe comme VRAI dossier dans le node_modules du -// paquet packé (packages/server/node_modules/@arboretum/shared). On le matérialise ici. -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); -const sharedDir = join(serverDir, '..', 'shared'); -const sharedDist = join(sharedDir, 'dist'); -const target = join(serverDir, 'node_modules', '@arboretum', 'shared'); - -if (!existsSync(sharedDist)) { - console.error( - `vendor-shared: ${sharedDist} introuvable — lance "npm run build" (shared doit être compilé) avant le pack.`, - ); - process.exit(1); -} - -// package.json minimal et déterministe : on reprend les champs de résolution réels de -// shared (version incluse, pour rester synchro), sans scripts ni files inutiles au runtime. -const shared = JSON.parse(readFileSync(join(sharedDir, 'package.json'), 'utf8')); -const minimal = { - name: shared.name, - version: shared.version, - type: shared.type, - main: shared.main, - types: shared.types, - exports: shared.exports, -}; - -// Idempotent : on repart d'un dossier propre à chaque pack. -rmSync(target, { recursive: true, force: true }); -mkdirSync(target, { recursive: true }); -writeFileSync(join(target, 'package.json'), JSON.stringify(minimal, null, 2) + '\n'); -cpSync(sharedDist, join(target, 'dist'), { recursive: true }); - -console.log(`vendor-shared: embarqué ${shared.name}@${shared.version} -> ${target}`);