@starterlib/cli 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `starter-web manifeste` : engendre `manifest.json` dans le dossier livre, apres la construction.
3
+ *
4
+ * Un remote : ce que le service sert a `/ui/<nom>/manifest.json`, et que le BFF agrege (ADR 0028,
5
+ * plan SPA §2.3). Tout ce qui se DEDUIT du build est calcule ici, jamais ecrit a la main : les
6
+ * catalogues i18n sont renommes avec leur empreinte (caches un an), le sprite metier est retrouve
7
+ * par la sienne, les modules des routes doivent etre exposes, et l'empreinte de l'arborescence signe
8
+ * ce qui est livre. Le reste (routes, perimetres, API exigee) vient de `manifest.source.json`.
9
+ *
10
+ * Le train REQUIS et le socle CONSTRUIT se lisent dans `remoteEntry.json` : la plage que la
11
+ * federation exige pour `@starterlib/core`, et la version qu'elle a embarquee. Les copies lisaient le
12
+ * train dans `federation.config.mjs` par expression reguliere, sous deux formes deja divergentes ; la
13
+ * plage annoncee au BFF est desormais, par construction, celle que la federation applique.
14
+ *
15
+ * ⚠️ IDEMPOTENTE (lot 174) : relancee apres une premiere passe, elle retrouve les catalogues deja
16
+ * haches au lieu d'echouer sur un fichier renomme, et retire une empreinte perimee avec ses variantes
17
+ * compressees.
18
+ *
19
+ * L'hote : le manifeste de la coquille, que le BFF lit pour dire aux remotes quel train il embarque.
20
+ */
21
+ import { execFileSync } from 'node:child_process';
22
+ import { createHash } from 'node:crypto';
23
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { empreinteCourte, fichiers, lireJson, relatif } from '../commun.mjs';
26
+
27
+ const LANGUES = ['fr', 'en'];
28
+
29
+ function commit(racine) {
30
+ if (process.env.GIT_COMMIT) return process.env.GIT_COMMIT;
31
+ try {
32
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
33
+ cwd: racine,
34
+ encoding: 'utf8',
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ }).trim();
37
+ } catch {
38
+ return 'local';
39
+ }
40
+ }
41
+
42
+ /** Le partage de `paquet` dans `remoteEntry.json` : plage exigee et version embarquee. */
43
+ function partage(entree, paquet) {
44
+ const trouve = (entree.shared ?? []).find((element) => element.packageName === paquet);
45
+ if (trouve === undefined) {
46
+ throw new Error(
47
+ `${paquet} n'est pas partage par remoteEntry.json : federation.config.mjs doit appeler ` +
48
+ 'partageDuSocle(train) et partager Angular (plan SPA, section 2.4).',
49
+ );
50
+ }
51
+ return trouve;
52
+ }
53
+
54
+ /** Retire un fichier et ses variantes precompressees. */
55
+ function retirer(chemin) {
56
+ for (const variante of [chemin, `${chemin}.br`, `${chemin}.gz`])
57
+ rmSync(variante, { force: true });
58
+ }
59
+
60
+ /** Le catalogue `langue` du perimetre `nom`, hache ; idempotent. Rend son nom de fichier. */
61
+ function hacherLeCatalogue(dossier, nom, langue) {
62
+ const brut = join(dossier, `${nom}.${langue}.json`);
63
+ const motif = new RegExp(`^${nom}\\.${langue}-[A-Z0-9]{8}\\.json$`);
64
+ const haches = existsSync(dossier)
65
+ ? readdirSync(dossier).filter((fichier) => motif.test(fichier))
66
+ : [];
67
+ if (existsSync(brut)) {
68
+ const hache = `${nom}.${langue}-${empreinteCourte(readFileSync(brut))}.json`;
69
+ for (const ancien of haches) if (ancien !== hache) retirer(join(dossier, ancien));
70
+ renameSync(brut, join(dossier, hache));
71
+ return hache;
72
+ }
73
+ if (haches.length === 1) return haches[0];
74
+ throw new Error(
75
+ `i18n/${nom}.${langue}.json absent de la construction (${String(haches.length)} variante(s) hachee(s)).`,
76
+ );
77
+ }
78
+
79
+ /** L'empreinte SRI de l'arborescence livree, hors manifeste et variantes precompressees. */
80
+ function empreinteDeLArborescence(sortie) {
81
+ const hachage = createHash('sha256');
82
+ for (const fichier of fichiers(sortie)) {
83
+ const nom = relatif(sortie, fichier);
84
+ // Les variantes derivent des fichiers deja haches : les compter ferait dependre l'empreinte de
85
+ // l'ordre des etapes.
86
+ if (nom === 'manifest.json' || nom.endsWith('.br') || nom.endsWith('.gz')) continue;
87
+ hachage.update(nom).update('\0').update(readFileSync(fichier)).update('\0');
88
+ }
89
+ return `sha256-${hachage.digest('base64')}`;
90
+ }
91
+
92
+ /** Ecrit le manifeste ; rend l'objet ecrit. */
93
+ export function ecrireLeManifeste(projet) {
94
+ const entree = lireJson(join(projet.sortie, 'remoteEntry.json'));
95
+ if (entree === null) {
96
+ throw new Error(`${projet.sortieRelative}/remoteEntry.json absent : construire d'abord.`);
97
+ }
98
+ const core = partage(entree, '@starterlib/core');
99
+ const angular = partage(entree, '@angular/core').version.split('.').slice(0, 2).join('.');
100
+ const version = process.env.VERSION ?? '0.0.0-local';
101
+
102
+ if (projet.role === 'hote') {
103
+ const manifeste = {
104
+ nom: 'socle',
105
+ version,
106
+ construitLe: new Date().toISOString(),
107
+ socle: { construit: core.version },
108
+ angular,
109
+ };
110
+ writeFileSync(join(projet.sortie, 'manifest.json'), `${JSON.stringify(manifeste, null, 2)}\n`);
111
+ return manifeste;
112
+ }
113
+
114
+ const { source, nom } = projet;
115
+ const prefixe = `/ui/${nom}`;
116
+ const exposes = new Set((entree.exposes ?? []).map((expose) => expose.key));
117
+ for (const route of source.routes ?? []) {
118
+ if (!exposes.has(route.module)) {
119
+ throw new Error(
120
+ `route ${String(route.prefixe)} : module ${String(route.module)} absent des exposes (${[...exposes].join(', ')}).`,
121
+ );
122
+ }
123
+ }
124
+
125
+ const catalogues = {};
126
+ for (const langue of LANGUES) {
127
+ catalogues[langue] =
128
+ `${prefixe}/i18n/${hacherLeCatalogue(join(projet.sortie, 'i18n'), nom, langue)}`;
129
+ }
130
+
131
+ const icones = join(projet.sortie, 'icones');
132
+ const sprite = existsSync(icones)
133
+ ? readdirSync(icones).find((fichier) => /^sprite-[A-Z0-9]{8}\.svg$/.test(fichier))
134
+ : undefined;
135
+ if (sprite === undefined) {
136
+ throw new Error(
137
+ `sprite metier absent de ${projet.sortieRelative}/icones : starter-web sprite d'abord.`,
138
+ );
139
+ }
140
+
141
+ const manifeste = {
142
+ nom,
143
+ version,
144
+ commit: commit(projet.racine),
145
+ construitLe: new Date().toISOString(),
146
+ // Format SRI (`sha256-<base64>`), celui que le BFF valide (ValidationDuManifeste.java).
147
+ empreinte: empreinteDeLArborescence(projet.sortie),
148
+ remoteEntry: `${prefixe}/remoteEntry.json`,
149
+ socle: { requis: core.requiredVersion, construit: core.version },
150
+ angular,
151
+ pleinePage: projet.pleinePage,
152
+ routes: source.routes,
153
+ i18n: { perimetres: source.i18n?.perimetres, catalogues },
154
+ sprite: `${prefixe}/icones/${sprite}`,
155
+ apiExigee: source.apiExigee,
156
+ };
157
+ writeFileSync(join(projet.sortie, 'manifest.json'), `${JSON.stringify(manifeste, null, 2)}\n`);
158
+ return manifeste;
159
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `starter-web precompresser [dossier]` : ecrit les variantes `.br` et `.gz` a cote de chaque actif
3
+ * livre.
4
+ *
5
+ * POURQUOI CETTE ETAPE EXISTE
6
+ *
7
+ * Le serveur sait DEJA servir une variante precompressee : `ActifsStatiques.servir()` cherche
8
+ * `<chemin>.br` puis `<chemin>.gz` selon l'`Accept-Encoding`, pose `Content-Encoding`, `Vary` et un
9
+ * ETag qui porte l'encodage. Il ne compresse rien lui-meme, par choix. Seulement, aucune chaine de
10
+ * build n'ecrivait ces fichiers — mesure du 2026-09-19 sur le BFF reel : le point d'entree de la
11
+ * coquille partait en 77 547 octets, la ou le brotli en rend environ 21 000.
12
+ *
13
+ * DEUX REGLES QUI ONT UNE RAISON
14
+ *
15
+ * - `index.html` est EXCLU : le serveur le reecrit a chaque reponse (nonce de la politique de
16
+ * securite, absolutisation des adresses, preferences de la personne). Une variante figee serait
17
+ * perimee des la premiere reponse.
18
+ * - une variante n'est ecrite que si elle est PLUS PETITE que l'original : sur un fichier
19
+ * minuscule, l'en-tete de compression coute plus qu'il ne rapporte.
20
+ *
21
+ * CORRIGE PAR RAPPORT AUX COPIES (lot 174)
22
+ *
23
+ * - le dossier se DONNE (argument, ou le dossier livre du projet) : les copies le derivaient de
24
+ * leur propre emplacement, et rien ne pouvait recompresser une fixture reecrite, ni eprouver la
25
+ * garde sur un dossier d'essai ;
26
+ * - une variante ORPHELINE (son original a disparu : un catalogue renomme par le manifeste, un
27
+ * morceau d'une construction precedente) est retiree, et une variante devenue inutile aussi :
28
+ * le serveur servirait sinon un contenu que plus rien ne produit.
29
+ *
30
+ * L'etape vient APRES la generation du manifeste, qui renomme les catalogues i18n.
31
+ */
32
+ import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
33
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
34
+ import { extname } from 'node:path';
35
+ import { fichiers, ko, relatif } from '../commun.mjs';
36
+
37
+ /** Exactement ce que le serveur declare compressible (ActifsStatiques.EXTENSIONS_COMPRESSIBLES). */
38
+ export const COMPRESSIBLES = new Set([
39
+ '.js',
40
+ '.mjs',
41
+ '.css',
42
+ '.json',
43
+ '.svg',
44
+ '.webmanifest',
45
+ '.txt',
46
+ ]);
47
+
48
+ /** Reecrit a chaque reponse par le serveur : une variante figee serait fausse. */
49
+ const EXCLUS = new Set(['index.html']);
50
+
51
+ /**
52
+ * Precompresse `dossier`. Rend `{ compresses, orphelines, octetsAvant, octetsBrotli, sansVariante }`,
53
+ * `sansVariante` listant les `.js` qui devraient avoir une variante brotli et n'en ont pas.
54
+ */
55
+ export function precompresser(dossier) {
56
+ const tous = fichiers(dossier);
57
+ let orphelines = 0;
58
+ for (const fichier of tous) {
59
+ const extension = extname(fichier);
60
+ if (extension !== '.br' && extension !== '.gz') continue;
61
+ const original = fichier.slice(0, -extension.length);
62
+ if (!existsSync(original) || EXCLUS.has(relatif(dossier, original))) {
63
+ rmSync(fichier);
64
+ orphelines += 1;
65
+ }
66
+ }
67
+
68
+ let compresses = 0;
69
+ let octetsAvant = 0;
70
+ let octetsBrotli = 0;
71
+ const sansGain = new Set();
72
+ for (const fichier of fichiers(dossier)) {
73
+ const extension = extname(fichier);
74
+ if (extension === '.br' || extension === '.gz') continue;
75
+ if (!COMPRESSIBLES.has(extension) || EXCLUS.has(relatif(dossier, fichier))) continue;
76
+ const contenu = readFileSync(fichier);
77
+ const brotli = brotliCompressSync(contenu, {
78
+ params: {
79
+ [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
80
+ [constants.BROTLI_PARAM_SIZE_HINT]: contenu.length,
81
+ },
82
+ });
83
+ const gzip = gzipSync(contenu, { level: 9 });
84
+ let ecrit = false;
85
+ for (const [variante, compresse] of [
86
+ [`${fichier}.br`, brotli],
87
+ [`${fichier}.gz`, gzip],
88
+ ]) {
89
+ if (compresse.length < contenu.length) {
90
+ writeFileSync(variante, compresse);
91
+ ecrit = true;
92
+ } else {
93
+ // Une variante d'une passe precedente, devenue plus lourde que l'original : retiree.
94
+ rmSync(variante, { force: true });
95
+ }
96
+ }
97
+ if (ecrit) {
98
+ compresses += 1;
99
+ octetsAvant += contenu.length;
100
+ octetsBrotli += Math.min(brotli.length, contenu.length);
101
+ } else {
102
+ sansGain.add(fichier);
103
+ }
104
+ }
105
+
106
+ const sansVariante = fichiers(dossier)
107
+ .filter((fichier) => extname(fichier) === '.js' && !sansGain.has(fichier))
108
+ .filter((fichier) => !existsSync(`${fichier}.br`))
109
+ .map((fichier) => relatif(dossier, fichier));
110
+ return { compresses, orphelines, octetsAvant, octetsBrotli, sansVariante };
111
+ }
112
+
113
+ /** Le compte rendu d'une precompression, et ses defauts (une etape devenue inutile en est un). */
114
+ export function juger({ compresses, orphelines, octetsAvant, octetsBrotli, sansVariante }) {
115
+ const gain = octetsAvant === 0 ? 0 : Math.round((1 - octetsBrotli / octetsAvant) * 100);
116
+ const resume =
117
+ `${String(compresses)} actif(s), ${String(ko(octetsAvant))} Ko bruts -> ` +
118
+ `${String(ko(octetsBrotli))} Ko en brotli (-${String(gain)} %)` +
119
+ (orphelines > 0 ? `, ${String(orphelines)} variante(s) orpheline(s) retiree(s).` : '.');
120
+ const defauts = [];
121
+ // Une etape qui ne compresserait plus rien est une faute ; un actif que la compression n'allege
122
+ // pas (un morceau de 60 octets) n'a pas de variante, par la seconde regle.
123
+ if (compresses === 0) defauts.push('aucun actif compresse : l etape est devenue inutile.');
124
+ if (sansVariante.length > 0)
125
+ defauts.push(`actifs .js sans variante brotli : ${sansVariante.join(', ')}.`);
126
+ return { resume, defauts };
127
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `starter-web sprite` : pose l'empreinte du sprite avant la construction, et la donne au code.
3
+ *
4
+ * - Un remote : son sprite METIER (`src/icones/sprite.svg`) est copie sous
5
+ * `public/icones/sprite-<empreinte>.svg`, et `src/app/icones/sprite-genere.ts` engendre
6
+ * `CHEMIN_DU_SPRITE_METIER` : le code du remote et son manifeste pointent sur le MEME fichier,
7
+ * cache un an (plan SPA §2.8, F15).
8
+ * - L'hote : le sprite du SOCLE (`@starterlib/ui/assets/icones/sprite.svg`) est copie sous
9
+ * `public/assets/icones/sprite-<empreinte>.svg`, et `src/app/sprite-genere.ts` engendre
10
+ * `CHEMIN_DU_SPRITE`, que `provideStarterUi` recoit hache.
11
+ *
12
+ * Les sorties sont ignorees par git ; `start`, `build`, `test` et `lint` relancent cette commande.
13
+ * Une ancienne empreinte est retiree : deux sprites livres, et le manifeste pourrait annoncer le
14
+ * mauvais.
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { empreinteCourte } from '../commun.mjs';
19
+
20
+ const SPRITE_HACHE = /^sprite-[A-Z0-9]{8}\.svg$/;
21
+
22
+ /** Les chemins du sprite selon le role. */
23
+ function reglages(projet) {
24
+ if (projet.role === 'hote') {
25
+ return {
26
+ source: join(
27
+ projet.racine,
28
+ 'node_modules',
29
+ '@starterlib',
30
+ 'ui',
31
+ 'assets',
32
+ 'icones',
33
+ 'sprite.svg',
34
+ ),
35
+ dossier: join(projet.racine, 'public', 'assets', 'icones'),
36
+ adresse: (fichier) => `/ui/${projet.nom}/assets/icones/${fichier}`,
37
+ genere: join(projet.racine, 'src', 'app', 'sprite-genere.ts'),
38
+ constante: 'CHEMIN_DU_SPRITE',
39
+ };
40
+ }
41
+ return {
42
+ source: join(projet.racine, 'src', 'icones', 'sprite.svg'),
43
+ dossier: join(projet.racine, 'public', 'icones'),
44
+ adresse: (fichier) => `/ui/${projet.nom}/icones/${fichier}`,
45
+ genere: join(projet.racine, 'src', 'app', 'icones', 'sprite-genere.ts'),
46
+ constante: 'CHEMIN_DU_SPRITE_METIER',
47
+ };
48
+ }
49
+
50
+ /** Pose le sprite hache et sa constante ; rend l'adresse servie. */
51
+ export function poserLeSprite(projet) {
52
+ const { source, dossier, adresse, genere, constante } = reglages(projet);
53
+ if (!existsSync(source)) {
54
+ throw new Error(
55
+ `sprite absent : ${source}${projet.role === 'hote' ? " (pnpm install d'abord)" : ''}.`,
56
+ );
57
+ }
58
+ const contenu = readFileSync(source);
59
+ const fichier = `sprite-${empreinteCourte(contenu)}.svg`;
60
+ mkdirSync(dossier, { recursive: true });
61
+ for (const existant of readdirSync(dossier)) {
62
+ if (SPRITE_HACHE.test(existant) && existant !== fichier) rmSync(join(dossier, existant));
63
+ }
64
+ writeFileSync(join(dossier, fichier), contenu);
65
+ const chemin = adresse(fichier);
66
+ mkdirSync(join(genere, '..'), { recursive: true });
67
+ writeFileSync(
68
+ genere,
69
+ '// Engendre par starter-web sprite (@starterlib/cli) : ne pas modifier, ne pas versionner.\n' +
70
+ `export const ${constante} = '${chemin}';\n`,
71
+ );
72
+ return chemin;
73
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `starter-web i18n:types` (lot 178, constat N21) : le type des cles de chaque perimetre, engendre
3
+ * depuis son catalogue francais, dans `src/app/i18n/cles.ts`.
4
+ *
5
+ * `injecterTraduction('catalog')` rendait `(cle: string) => string` : une faute de frappe dans
6
+ * `t('catalog.pages.produts')` compilait, et n'apparaissait qu'a l'ecran. Avec le type engendre,
7
+ * `injecterTraduction<CleCatalog>('catalog')` refuse a la compilation une cle que le catalogue ne
8
+ * porte pas. Le fichier est versionne (une cle ajoutee se lit a la revue) ; l'etape `i18n` le veut a
9
+ * jour.
10
+ */
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ /** Ou vit le type engendre. */
15
+ export function cheminDesTypesDeCles(racine) {
16
+ return join(racine, 'src', 'app', 'i18n', 'cles.ts');
17
+ }
18
+
19
+ /** `catalog` donne `CleCatalog` ; `mon-remote`, `CleMonRemote`. */
20
+ export function nomDuType(perimetre) {
21
+ return `Cle${perimetre
22
+ .split(/[^a-zA-Z0-9]+/)
23
+ .filter((morceau) => morceau !== '')
24
+ .map((morceau) => morceau[0].toUpperCase() + morceau.slice(1))
25
+ .join('')}`;
26
+ }
27
+
28
+ /** Le contenu du fichier : un type par perimetre, les cles dans l'ordre alphabetique. */
29
+ export function typesDeCles(perimetres, catalogues) {
30
+ const lignes = [
31
+ '// Engendre par starter-web i18n:types (@starterlib/cli) depuis public/i18n/<perimetre>.fr.json :',
32
+ '// ne pas modifier a la main. Une cle absente du catalogue est une erreur de compilation.',
33
+ ];
34
+ for (const perimetre of perimetres) {
35
+ const cles = Object.keys(catalogues[perimetre]?.fr ?? {}).sort();
36
+ lignes.push('', `/** Les cles du perimetre ${perimetre}. */`);
37
+ const enLigne = `export type ${nomDuType(perimetre)} = ${
38
+ cles.length === 0 ? 'never' : cles.map((cle) => `'${cle}'`).join(' | ')
39
+ };`;
40
+ // La forme que Prettier (largeur 100) donnerait : une ligne si elle tient, une cle par ligne
41
+ // sinon. Le fichier engendre passe ainsi `prettier --check` sans etre ecarte.
42
+ if (enLigne.length <= 100) {
43
+ lignes.push(enLigne);
44
+ continue;
45
+ }
46
+ lignes.push(`export type ${nomDuType(perimetre)} =`);
47
+ cles.forEach((cle, indice) => {
48
+ lignes.push(` | '${cle}'${indice === cles.length - 1 ? ';' : ''}`);
49
+ });
50
+ }
51
+ return `${lignes.join('\n')}\n`;
52
+ }
53
+
54
+ /** Ecrit le fichier ; rend son chemin. */
55
+ export function ecrireLesTypesDeCles(racine, perimetres, catalogues) {
56
+ const chemin = cheminDesTypesDeCles(racine);
57
+ mkdirSync(dirname(chemin), { recursive: true });
58
+ writeFileSync(chemin, typesDeCles(perimetres, catalogues));
59
+ return chemin;
60
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Etape `budget` : les tailles LIVREES, dans l'unite que le serveur envoie — la variante `.br`
3
+ * quand elle existe (ADR 0031, decision 5 : « deux budgets, dans l'unite que le navigateur
4
+ * telecharge »), le brotli qualite maximale sinon.
5
+ *
6
+ * Un remote (lot 177, constats A0905, A0920, A0968, A0831) : les copies ne pesaient que le module
7
+ * expose (`routes-*.js`, 0,3 Ko), jamais les pages qu'il charge paresseusement, et en gzip. Une page
8
+ * qui tirerait un paquet entier passait sans alerte. L'etape suit desormais le graphe : chaque
9
+ * module expose et chaque morceau LOCAL qu'un import dynamique atteint est une PAGE, pesee avec la
10
+ * cloture statique de ses morceaux locaux (60 Ko), et l'union de tout ce qui est charge ne depasse
11
+ * pas 250 Ko (plan SPA §2.8). Les paquets partages n'y sont pas : la coquille les paie une fois pour
12
+ * toutes. Chaque catalogue i18n : 30 Ko.
13
+ *
14
+ * L'hote : ce que le navigateur TELECHARGE avant le premier ecran (voir `cloture.mjs`), les paquets
15
+ * partages resolus par la carte d'importation.
16
+ */
17
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { brotliCompressSync, constants } from 'node:zlib';
20
+ import { cloture, departDeLaPage, totaux } from '../cloture.mjs';
21
+ import { fichiers, ko, lireJson, relatif } from '../commun.mjs';
22
+ import { natureDesFichiers } from '../livraison.mjs';
23
+
24
+ /** Les budgets d'un remote, en Ko brotli (plan SPA §2.8). */
25
+ export const BUDGETS_DU_REMOTE = { pageKo: 60, totalKo: 250, catalogueKo: 30 };
26
+
27
+ /**
28
+ * Les seuils de l'hote, en Ko brotli (ADR 0031) : 300 pour le premier ecran reel, 280 pour la
29
+ * charge initiale, ramenes au socle 0.10.0 (releves et historique dans l'ADR).
30
+ *
31
+ * ⚠️ Ce ne sont pas la cible du plan SPA §2.8 (170 Ko). L'ecart restant est STRUCTUREL : un paquet
32
+ * partage en singleton part ENTIER, parce que n'importe quel remote peut en avoir besoin. Un seuil
33
+ * se baisse ; il ne se leve jamais sans mesure ecrite et sans date.
34
+ */
35
+ export const BUDGETS_DE_L_HOTE = { premierEcranKo: 300, chargeInitialeKo: 280, cibleDuPlanKo: 170 };
36
+
37
+ /** Les imports DYNAMIQUES d'un module : les pages qu'il charge a la demande. */
38
+ const IMPORT_DYNAMIQUE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
39
+
40
+ /**
41
+ * La carte des morceaux LOCAUX d'un remote : avec `denseChunking`, la federation les importe par
42
+ * `@nf-internal/<morceau>`, que la carte d'importation resout (`import("@nf-internal/produits-X")`),
43
+ * et non par un chemin relatif. Les morceaux partages n'y sont pas : la coquille les paie.
44
+ */
45
+ export function carteDesMorceauxLocaux(sortie, partages) {
46
+ const carte = {};
47
+ for (const chemin of fichiers(sortie)) {
48
+ const nom = relatif(sortie, chemin);
49
+ if (!nom.endsWith('.js') || partages.has(nom)) continue;
50
+ carte[`@nf-internal/${nom.slice(0, -'.js'.length)}`] = nom;
51
+ }
52
+ return carte;
53
+ }
54
+
55
+ /** Ce que le reseau transporte d'un fichier : sa variante brotli, ou sa compression maximale. */
56
+ function brotliDe(chemin) {
57
+ if (existsSync(`${chemin}.br`)) return statSync(`${chemin}.br`).size;
58
+ return brotliCompressSync(readFileSync(chemin), {
59
+ params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY },
60
+ }).length;
61
+ }
62
+
63
+ /**
64
+ * Les pages d'un remote : chaque module expose, puis chaque morceau local qu'un import dynamique
65
+ * atteint, avec la cloture statique de ses morceaux locaux. `partages` : les fichiers du cadre
66
+ * partage, jamais suivis. Rend une Map du fichier de la page vers `{ nom, poids }` (`poids` : la Map
67
+ * de `cloture`).
68
+ */
69
+ export function pagesDuRemote(sortie, exposes, partages = new Set()) {
70
+ const carte = carteDesMorceauxLocaux(sortie, partages);
71
+ const local = (specificateur) =>
72
+ specificateur.startsWith('.')
73
+ ? specificateur.replace(/^\.\//, '')
74
+ : (carte[specificateur] ?? null);
75
+ const pages = new Map();
76
+ const aVisiter = exposes.map((expose) => ({ fichier: expose.outFileName, nom: expose.key }));
77
+ while (aVisiter.length > 0) {
78
+ const { fichier, nom } = aVisiter.shift();
79
+ if (pages.has(fichier) || !existsSync(join(sortie, fichier))) continue;
80
+ // Un specificateur nu hors de la carte (un paquet partage) n'est pas suivi : la coquille le paie.
81
+ const poids = cloture(sortie, [fichier], carte, 0);
82
+ pages.set(fichier, { nom, poids });
83
+ for (const chemin of poids.keys()) {
84
+ if (!chemin.endsWith('.js')) continue;
85
+ for (const [, cible] of readFileSync(join(sortie, chemin), 'utf8').matchAll(
86
+ IMPORT_DYNAMIQUE,
87
+ )) {
88
+ const page = local(cible);
89
+ if (page !== null) aVisiter.push({ fichier: page, nom: page });
90
+ }
91
+ }
92
+ }
93
+ return pages;
94
+ }
95
+
96
+ function budgetDuRemote(projet) {
97
+ const entree = lireJson(join(projet.sortie, 'remoteEntry.json'));
98
+ if (entree === null) {
99
+ return { defauts: [`${projet.sortieRelative}/remoteEntry.json absent : construire d'abord.`] };
100
+ }
101
+ const defauts = [];
102
+ const details = [];
103
+ for (const expose of entree.exposes ?? []) {
104
+ if (!existsSync(join(projet.sortie, expose.outFileName))) {
105
+ defauts.push(`${expose.key} : ${expose.outFileName} annonce par remoteEntry.json, absent.`);
106
+ }
107
+ }
108
+ const pages = pagesDuRemote(
109
+ projet.sortie,
110
+ entree.exposes ?? [],
111
+ natureDesFichiers(entree).partages,
112
+ );
113
+ const charges = new Map();
114
+ for (const [fichier, { nom, poids }] of pages) {
115
+ const page = totaux(poids);
116
+ for (const [chemin, taille] of poids) charges.set(chemin, taille);
117
+ details.push(
118
+ `page ${nom} : ${String(ko(page.brotli))} Ko br (${String(page.fichiers)} fichiers)`,
119
+ );
120
+ if (ko(page.brotli) > BUDGETS_DU_REMOTE.pageKo) {
121
+ defauts.push(
122
+ `page ${nom} (${fichier}) : ${String(ko(page.brotli))} Ko br > ${String(BUDGETS_DU_REMOTE.pageKo)} Ko.`,
123
+ );
124
+ }
125
+ }
126
+ const total = totaux(charges);
127
+ if (ko(total.brotli) > BUDGETS_DU_REMOTE.totalKo) {
128
+ defauts.push(
129
+ `total charge : ${String(ko(total.brotli))} Ko br > ${String(BUDGETS_DU_REMOTE.totalKo)} Ko.`,
130
+ );
131
+ }
132
+ const dossierI18n = join(projet.sortie, 'i18n');
133
+ const catalogues = existsSync(dossierI18n)
134
+ ? readdirSync(dossierI18n).filter((fichier) => fichier.endsWith('.json'))
135
+ : [];
136
+ for (const catalogue of catalogues) {
137
+ const taille = brotliDe(join(dossierI18n, catalogue));
138
+ details.push(`i18n/${catalogue} : ${String(ko(taille))} Ko br`);
139
+ if (ko(taille) > BUDGETS_DU_REMOTE.catalogueKo) {
140
+ defauts.push(
141
+ `i18n/${catalogue} : ${String(ko(taille))} Ko br > ${String(BUDGETS_DU_REMOTE.catalogueKo)} Ko.`,
142
+ );
143
+ }
144
+ }
145
+ return {
146
+ defauts,
147
+ details,
148
+ resume: `${String(pages.size)} page(s), ${String(ko(total.brotli))} Ko br au total, dans leurs seuils.`,
149
+ };
150
+ }
151
+
152
+ function budgetDeLHote(projet) {
153
+ const index = join(projet.sortie, 'index.html');
154
+ if (!existsSync(index)) {
155
+ return { defauts: [`${projet.sortieRelative}/index.html absent : construire d'abord.`] };
156
+ }
157
+ const carte = lireJson(join(projet.sortie, 'importmap.json'))?.imports ?? {};
158
+ const depart = departDeLaPage(readFileSync(index, 'utf8'));
159
+ const initiale = totaux(cloture(projet.sortie, depart, carte, 1));
160
+ const premierEcran = cloture(projet.sortie, depart, carte, 2);
161
+ const ecran = totaux(premierEcran);
162
+ const details = [
163
+ `charge initiale ${String(ko(initiale.brotli))} Ko br (${String(initiale.fichiers)} fichiers)`,
164
+ `premier ecran reel ${String(ko(ecran.brotli))} Ko br (${String(ecran.fichiers)} fichiers)`,
165
+ ...[...premierEcran.entries()]
166
+ .sort((a, b) => b[1].brotli - a[1].brotli)
167
+ .slice(0, 5)
168
+ .map(([chemin, taille]) => ` ${String(ko(taille.brotli))} Ko br ${chemin}`),
169
+ ];
170
+ const defauts = [];
171
+ if (ko(initiale.brotli) > BUDGETS_DE_L_HOTE.chargeInitialeKo) {
172
+ defauts.push(
173
+ `charge initiale : ${String(ko(initiale.brotli))} Ko br > ${String(BUDGETS_DE_L_HOTE.chargeInitialeKo)} Ko.`,
174
+ );
175
+ }
176
+ if (ko(ecran.brotli) > BUDGETS_DE_L_HOTE.premierEcranKo) {
177
+ defauts.push(
178
+ `premier ecran : ${String(ko(ecran.brotli))} Ko br > ${String(BUDGETS_DE_L_HOTE.premierEcranKo)} Ko (garde de non-regression).`,
179
+ );
180
+ }
181
+ const avertissements =
182
+ ko(ecran.brotli) > BUDGETS_DE_L_HOTE.cibleDuPlanKo
183
+ ? [
184
+ `cible du plan SPA (section 2.8) : ${String(BUDGETS_DE_L_HOTE.cibleDuPlanKo)} Ko, ecart assume (ADR 0031).`,
185
+ ]
186
+ : [];
187
+ return { defauts, avertissements, details, resume: 'premier ecran dans ses seuils.' };
188
+ }
189
+
190
+ export default {
191
+ nom: 'budget',
192
+ roles: ['remote', 'hote'],
193
+ executer: (projet) => (projet.role === 'hote' ? budgetDeLHote(projet) : budgetDuRemote(projet)),
194
+ };