@nodefony/frontend 10.0.0-alpha.1

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.
Files changed (56) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +338 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +63 -0
  6. package/dist/nodefony/command/frontend-build.js +68 -0
  7. package/dist/nodefony/command/frontend-dev.js +31 -0
  8. package/dist/nodefony/command/frontend-status.js +40 -0
  9. package/dist/nodefony/config/config.js +65 -0
  10. package/dist/nodefony/config/defineModuleConfig.js +34 -0
  11. package/dist/nodefony/interfaces/IFrontBuilder.js +1 -0
  12. package/dist/nodefony/interfaces/IFrontPreset.js +1 -0
  13. package/dist/nodefony/interfaces/IFrontendService.js +1 -0
  14. package/dist/nodefony/interfaces/IViteSupervisor.js +1 -0
  15. package/dist/nodefony/interfaces/index.js +1 -0
  16. package/dist/nodefony/service/FrontendService.js +708 -0
  17. package/dist/nodefony/service/ViteConfigGenerator.js +139 -0
  18. package/dist/nodefony/service/ViteProcessSupervisor.js +589 -0
  19. package/dist/nodefony/src/FrontendAdminApi.js +113 -0
  20. package/dist/nodefony/src/builders/ViteBuilder.js +75 -0
  21. package/dist/nodefony/src/errors/FrontendError.js +50 -0
  22. package/dist/nodefony/src/isolationGroups.js +116 -0
  23. package/dist/nodefony/src/presets/angular-vite.js +27 -0
  24. package/dist/nodefony/src/presets/react19-vite.js +26 -0
  25. package/dist/nodefony/src/presets/svelte5-vite.js +37 -0
  26. package/dist/nodefony/src/presets/vanilla-vite.js +17 -0
  27. package/dist/nodefony/src/presets/vue3-vite.js +23 -0
  28. package/dist/nodefony/src/remoteDev.js +157 -0
  29. package/dist/nodefony/src/template/TemplateHelper.js +255 -0
  30. package/dist/types/index.d.ts +51 -0
  31. package/dist/types/nodefony/command/frontend-build.d.ts +17 -0
  32. package/dist/types/nodefony/command/frontend-dev.d.ts +12 -0
  33. package/dist/types/nodefony/command/frontend-status.d.ts +14 -0
  34. package/dist/types/nodefony/config/config.d.ts +38 -0
  35. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -0
  36. package/dist/types/nodefony/interfaces/IFrontBuilder.d.ts +69 -0
  37. package/dist/types/nodefony/interfaces/IFrontPreset.d.ts +26 -0
  38. package/dist/types/nodefony/interfaces/IFrontendService.d.ts +77 -0
  39. package/dist/types/nodefony/interfaces/IViteSupervisor.d.ts +56 -0
  40. package/dist/types/nodefony/interfaces/index.d.ts +4 -0
  41. package/dist/types/nodefony/service/FrontendService.d.ts +230 -0
  42. package/dist/types/nodefony/service/ViteConfigGenerator.d.ts +66 -0
  43. package/dist/types/nodefony/service/ViteProcessSupervisor.d.ts +214 -0
  44. package/dist/types/nodefony/src/FrontendAdminApi.d.ts +63 -0
  45. package/dist/types/nodefony/src/builders/ViteBuilder.d.ts +17 -0
  46. package/dist/types/nodefony/src/errors/FrontendError.d.ts +34 -0
  47. package/dist/types/nodefony/src/isolationGroups.d.ts +89 -0
  48. package/dist/types/nodefony/src/presets/angular-vite.d.ts +15 -0
  49. package/dist/types/nodefony/src/presets/react19-vite.d.ts +9 -0
  50. package/dist/types/nodefony/src/presets/svelte5-vite.d.ts +13 -0
  51. package/dist/types/nodefony/src/presets/vanilla-vite.d.ts +9 -0
  52. package/dist/types/nodefony/src/presets/vue3-vite.d.ts +11 -0
  53. package/dist/types/nodefony/src/remoteDev.d.ts +107 -0
  54. package/dist/types/nodefony/src/template/TemplateHelper.d.ts +99 -0
  55. package/docs/index.md +925 -0
  56. package/package.json +80 -0
@@ -0,0 +1,708 @@
1
+ import config from "../config/config.js";
2
+ import { FrontendNoEntriesError, FrontendSupervisorStartError } from "../src/errors/FrontendError.js";
3
+ import { ViteBuilder } from "../src/builders/ViteBuilder.js";
4
+ import { allowedHostPatternForTemplate, detectRemoteDev, isValidOriginTemplate, viteAllowedHostFromPattern } from "../src/remoteDev.js";
5
+ import { ViteProcessSupervisor } from "./ViteProcessSupervisor.js";
6
+ import { TemplateHelper } from "../src/template/TemplateHelper.js";
7
+ import { familyPortBlocks, familyPortPlan, isolationGroup } from "../src/isolationGroups.js";
8
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
9
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
10
+ import { Module, Service, extend, injectable, stripTrailingSlashes } from "nodefony";
11
+ import path from "node:path";
12
+ import fs from "node:fs";
13
+ //#region nodefony/service/FrontendService.ts
14
+ /**
15
+ * Normalise un préfixe public : garantit un `/` en tête et en queue.
16
+ * `"_assets/x"` → `"/_assets/x/"`, `"/"` → `"/"`.
17
+ */
18
+ const normalizePublicPath = (p) => {
19
+ let s = p.trim();
20
+ if (!s.startsWith("/")) s = `/${s}`;
21
+ if (!s.endsWith("/")) s = `${s}/`;
22
+ return s.replace(/\/{2,}/g, "/");
23
+ };
24
+ /**
25
+ * Service injectable du module `@nodefony/frontend`.
26
+ *
27
+ * Cycle de vie :
28
+ * 1. construction : merge options par défaut + surcharge app (`module-frontend`).
29
+ * 2. `onKernelReady` : si dev + `autoStartInDevelopment` → start superviseur.
30
+ * 3. modules consommateurs appellent `registerEntry(...)` dans leur init.
31
+ * 4. terminate kernel : `stop()` superviseur.
32
+ *
33
+ * Branche POC `poc/frontend-child` : utilise `ViteProcessSupervisor` (spawn).
34
+ * Branche POC `poc/frontend-single` : remplacera par `ViteInProcSupervisor`.
35
+ */
36
+ let FrontendService = class FrontendService extends Service {
37
+ module;
38
+ cfg;
39
+ builder = new ViteBuilder();
40
+ entries = [];
41
+ /** Une instance Vite par famille d'isolation (`default`, `angular`, …). */
42
+ supervisors = /* @__PURE__ */ new Map();
43
+ /** Template helper par famille (route les `<script>` vers le bon port Vite). */
44
+ templateHelpers = /* @__PURE__ */ new Map();
45
+ /** Index inverse `entryName → famille`, pour router `renderTags`. */
46
+ entryFamily = /* @__PURE__ */ new Map();
47
+ /** Helper prod unique (lit les manifests) — `null` tant qu'on n'est pas en prod. */
48
+ prodHelper = null;
49
+ /**
50
+ * L'origine publique est-elle ÉPINGLÉE par une décision explicite
51
+ * (`frontend.publicOrigin` en config, ou plateforme de dev déporté détectée) ?
52
+ * `true` → la dérivation par `Host` est désactivée : un réglage voulu gagne
53
+ * toujours sur une déduction (cf ordre de priorité, README du module).
54
+ */
55
+ originPinned = false;
56
+ /**
57
+ * Ports que l'instance Vite de chaque famille PEUT prendre pour ce démarrage
58
+ * (bloc de la famille, port-retry compris) — `null` tant que `startDev` n'a
59
+ * pas établi le plan, et remis à `null` par `stopDev`.
60
+ *
61
+ * Alloué une fois par démarrage de développement, jamais en production
62
+ * (`startDev` n'y tourne pas) : aucun coût par requête.
63
+ */
64
+ plannedPortBlocks = null;
65
+ constructor(module) {
66
+ const merged = extend(true, {}, config, module.options ?? {});
67
+ super("frontend", module.container, module.notificationsCenter, merged);
68
+ this.module = module;
69
+ this.cfg = merged;
70
+ }
71
+ /** Base CDN normalisée (sans slash final). `""` = origine Nodefony. */
72
+ get assetBase() {
73
+ return stripTrailingSlashes(this.cfg.assetBaseUrl ?? "");
74
+ }
75
+ /**
76
+ * Résout l'URL publique d'un asset. Préfixe `p` par `assetBaseUrl` (CDN) si
77
+ * renseigné, sinon le renvoie tel quel (origine, chemin relatif). Les URLs
78
+ * absolues (`http(s)://…`) sont renvoyées inchangées. Helper template :
79
+ * `asset('/test/logo.png')` → `https://cdn.example.com/test/logo.png` ou
80
+ * `/test/logo.png` (assetBaseUrl vide).
81
+ */
82
+ assetUrl(p) {
83
+ if (/^https?:\/\//i.test(p)) return p;
84
+ const base = this.assetBase;
85
+ if (!base) return p;
86
+ return base + (p.startsWith("/") ? p : `/${p}`);
87
+ }
88
+ async init() {
89
+ this.log(`MODULE frontend service init`, "DEBUG");
90
+ this.kernel?.once("onServersReady", async () => {
91
+ const env = this.kernel?.environment;
92
+ if (env === "development" && this.cfg.autoStartInDevelopment) {
93
+ if (this.entries.length === 0) {
94
+ this.log("no frontend entries declared — Vite supervisor not started", "INFO");
95
+ return;
96
+ }
97
+ const names = this.entries.map((e) => e.entryName);
98
+ this.kernel?.fire("onFrontendStart", { bundles: names.length });
99
+ try {
100
+ await this.startDev();
101
+ } catch (e) {
102
+ this.log(e, "ERROR");
103
+ } finally {
104
+ let ready = 0;
105
+ for (const [, sup] of this.supervisors) {
106
+ const st = sup.status();
107
+ if (st.state !== "ready") continue;
108
+ ready++;
109
+ const scheme = st.https ? "https" : "http";
110
+ const url = `${st.origin ?? `${scheme}://${st.host}:${st.port}`}/`;
111
+ for (const e of st.entries) {
112
+ const fw = e.type.replace(/[0-9]+$/, "");
113
+ this.kernel?.reportBootLine("Frontend (Vite)", `${e.entryName.padEnd(24)}${url}${fw ? ` ${fw}` : ""}`);
114
+ }
115
+ }
116
+ this.kernel?.fire("onFrontendReady", {
117
+ bundles: names.length,
118
+ names,
119
+ ready
120
+ });
121
+ }
122
+ } else if (env !== "development") await this.setupProd();
123
+ });
124
+ this.kernel?.once("onTerminate", async () => {
125
+ try {
126
+ await this.stopDev();
127
+ } catch {}
128
+ });
129
+ return this;
130
+ }
131
+ /**
132
+ * Enregistre une déclaration frontend d'un module consommateur.
133
+ *
134
+ * À appeler dans le `initialize()` ou `onKernelReady()` du module
135
+ * consommateur — toujours AVANT `onReady` du kernel (sinon le supervisor
136
+ * démarre sans cette entrée).
137
+ */
138
+ registerEntry(consumerModule, declaration) {
139
+ const moduleRoot = consumerModule.path ?? process.cwd();
140
+ const root = path.resolve(moduleRoot, declaration.root ?? this.cfg.defaultRoot);
141
+ const outDir = path.resolve(moduleRoot, declaration.outDir ?? this.cfg.defaultOutDir);
142
+ const absEntry = path.resolve(moduleRoot, declaration.entry);
143
+ const relEntry = path.relative(root, absEntry);
144
+ const entryName = declaration.name ?? consumerModule.name;
145
+ const entry = {
146
+ moduleName: consumerModule.name,
147
+ entryName,
148
+ type: declaration.type,
149
+ root,
150
+ entryFile: relEntry,
151
+ outDir,
152
+ publicPath: normalizePublicPath(declaration.publicPath ?? `/_assets/${entryName}`),
153
+ apiProxyPaths: declaration.apiProxyPaths ?? []
154
+ };
155
+ this.entries.push(entry);
156
+ this.log(`registered entry: ${entry.entryName} (${entry.type}) from "${entry.moduleName}"`, "INFO");
157
+ return entry;
158
+ }
159
+ listEntries() {
160
+ return this.entries;
161
+ }
162
+ status() {
163
+ const primary = this.supervisors.get("default") ?? [...this.supervisors.values()][0];
164
+ if (!primary) return {
165
+ state: "idle",
166
+ host: this.cfg.devHost,
167
+ origin: null,
168
+ port: null,
169
+ pid: null,
170
+ https: !!this.cfg.https,
171
+ restartCount: 0,
172
+ healthFailures: 0,
173
+ portRetries: 0,
174
+ lastError: null,
175
+ entries: this.entries
176
+ };
177
+ return primary.status();
178
+ }
179
+ statusAll() {
180
+ return [...this.supervisors.entries()].map(([family, s]) => ({
181
+ family,
182
+ status: s.status()
183
+ }));
184
+ }
185
+ /**
186
+ * Démarre une instance Vite **par famille d'isolation** (multi-supervisor).
187
+ *
188
+ * Résilience : chaque famille démarre indépendamment (`Promise.allSettled`).
189
+ * Une famille qui échoue (ex. Angular) est isolée — elle ne fait jamais
190
+ * échouer les autres ni le backend. `startDev` ne rejette que si **aucune**
191
+ * famille n'a pu démarrer.
192
+ */
193
+ async startDev() {
194
+ if (this.entries.length === 0) throw new FrontendNoEntriesError();
195
+ if (this.supervisors.size > 0 && [...this.supervisors.values()].some((s) => s.status().state === "ready")) return;
196
+ const backendOrigin = `${this.cfg.backendProtocol}://${this.cfg.backendHost}:${this.resolveBackendPort()}`;
197
+ const https = this.resolveHttps();
198
+ const publicOriginTemplate = this.resolvePublicOriginTemplate();
199
+ this.originPinned = publicOriginTemplate !== void 0;
200
+ const allowedHosts = this.viteAllowedHosts(publicOriginTemplate);
201
+ const nodeEnv = this.kernel?.environment;
202
+ const extraEnv = this.cfg.viteEnv ?? {};
203
+ const groups = this.groupEntriesByFamily();
204
+ const portPlan = familyPortPlan(this.cfg.devPort, [...groups.keys()], this.cfg.resilience?.portRetryAttempts ?? 3);
205
+ const families = [...portPlan.keys()];
206
+ if (publicOriginTemplate && !publicOriginTemplate.includes("{port}") && families.length > 1) this.log(`publicOrigin figée (« ${publicOriginTemplate} ») avec ${families.length} familles Vite (${families.join(", ")}) — une origine ne peut en servir qu'UNE : utiliser {port} (ex. https://host:{port}) pour suivre chaque famille`, "WARNING");
207
+ this.plannedPortBlocks = familyPortBlocks(portPlan, this.cfg.resilience?.portRetryAttempts ?? 3);
208
+ this.#registerCsp();
209
+ this.fire("frontend:starting", {
210
+ backendOrigin,
211
+ entries: this.entries
212
+ });
213
+ const total = this.entries.length;
214
+ let done = 0;
215
+ (await Promise.allSettled(families.map((family) => {
216
+ const famSize = groups.get(family).length;
217
+ return this.startFamily(family, groups.get(family), portPlan.get(family), {
218
+ backendOrigin,
219
+ https,
220
+ nodeEnv,
221
+ extraEnv,
222
+ publicOriginTemplate,
223
+ allowedHosts
224
+ }).finally(() => {
225
+ done += famSize;
226
+ this.kernel?.fire("onFrontendProgress", {
227
+ ready: done,
228
+ total
229
+ });
230
+ });
231
+ }))).forEach((res, i) => {
232
+ if (res.status === "rejected") {
233
+ const reason = res.reason;
234
+ this.log(`frontend family "${families[i]}" failed to start (isolated): ${reason?.message ?? reason}`, "ERROR");
235
+ }
236
+ });
237
+ if ([...this.supervisors.values()].filter((s) => s.status().state === "ready").length === 0) {
238
+ const err = new FrontendSupervisorStartError("no frontend family could start");
239
+ this.fire("frontend:error", err);
240
+ throw err;
241
+ }
242
+ this.#registerCsp();
243
+ this.fire("frontend:ready", this.status());
244
+ }
245
+ /**
246
+ * Port backend que Vite doit proxifier — le port RÉELLEMENT écouté, pas celui
247
+ * qu'on espérait.
248
+ *
249
+ * `config.backendPort` (5151) n'est qu'une intention : avec
250
+ * `servers.portPolicy: "auto"`, un port occupé fait glisser l'écoute du backend
251
+ * (5151 → 5153). Un proxy figé sur 5151 enverrait alors les appels API du front
252
+ * vers le serveur d'une AUTRE app — au mieux des 404, au pire les données du
253
+ * voisin. On lit donc le port sur le serveur lui-même.
254
+ *
255
+ * Résolution par NOM (`server-http`), jamais par import : `@nodefony/frontend`
256
+ * ne dépend pas de `@nodefony/http` (cycle via la config d'app).
257
+ */
258
+ resolveBackendPort() {
259
+ const server = this.container?.get?.("server-http");
260
+ const real = server?.active && server.port ? server.port : 0;
261
+ if (real > 0 && real !== this.cfg.backendPort) this.log(`backend à l'écoute sur ${real} (et non ${this.cfg.backendPort}) — le proxy Vite suit le port réel`, "INFO");
262
+ return real > 0 ? real : this.cfg.backendPort;
263
+ }
264
+ /**
265
+ * Résout les certificats HTTPS partagés (service `certificates` de
266
+ * @nodefony/http) si `https: true`. Pas de duplication — mêmes PEM que
267
+ * `server-https` (5152). Retombe sur HTTP avec un warning si indisponible.
268
+ */
269
+ resolveHttps() {
270
+ if (!this.cfg.https) return void 0;
271
+ const certs = this.container?.get?.("certificates");
272
+ if (!certs?.privateKeyPath || !certs?.certPath) {
273
+ this.log("https: true requested but `certificates` service unavailable — falling back to HTTP", "WARNING");
274
+ return;
275
+ }
276
+ return {
277
+ keyPath: certs.privateKeyPath,
278
+ certPath: certs.certPath
279
+ };
280
+ }
281
+ /**
282
+ * Template d'origine publique Vite (P14.17). Priorité : `frontend.publicOrigin`
283
+ * (config, validée — invalide = ERROR + ignorée, jamais un boot cassé) puis
284
+ * détection de plateforme (Codespaces/Gitpod — variables documentées de la
285
+ * plateforme, qu'on lit sans les posséder). `undefined` = dérivation locale.
286
+ * Chaque adaptation est JOURNALISÉE : on doit pouvoir lire dans le boot
287
+ * pourquoi les `<script>` pointent où ils pointent.
288
+ */
289
+ resolvePublicOriginTemplate() {
290
+ const cfgOrigin = this.cfg.publicOrigin;
291
+ if (cfgOrigin) {
292
+ if (!isValidOriginTemplate(cfgOrigin)) {
293
+ this.log(`frontend.publicOrigin invalide (« ${cfgOrigin} ») — attendu scheme://host[:port|:{port}] sans chemin ; origine locale utilisée`, "ERROR");
294
+ return;
295
+ }
296
+ this.log(`origine publique Vite (config) : ${cfgOrigin}`, "INFO");
297
+ return cfgOrigin;
298
+ }
299
+ const detected = detectRemoteDev(process.env);
300
+ if (detected) {
301
+ this.log(`dev déporté détecté (${detected.provider}) — origine publique Vite : ` + detected.originTemplate, "INFO");
302
+ return detected.originTemplate;
303
+ }
304
+ }
305
+ /**
306
+ * `server.allowedHosts` pour Vite. Vite accepte d'office IP et `localhost` ;
307
+ * cette liste ne porte que les NOMS. Source des noms légitimes = la MÊME que
308
+ * la barrière Host de Nodefony (`kernel.domain` + `trustedHosts` http) — une
309
+ * seule liste à maintenir : autoriser un hôte dans `trustedHosts` ouvre à la
310
+ * fois la barrière 421 ET Vite. S'y ajoute l'hôte du template d'origine
311
+ * publique. `trustedHosts: true` (barrière déléguée au reverse-proxy) →
312
+ * `true` (même délégation). Un pattern non exprimable chez Vite est ANNONCÉ.
313
+ */
314
+ viteAllowedHosts(publicOriginTemplate) {
315
+ const th = (this.container?.get?.("HttpKernel"))?.trustedHosts;
316
+ if (th === true) return true;
317
+ const hosts = /* @__PURE__ */ new Set();
318
+ if (this.kernel?.domain) {
319
+ const d = viteAllowedHostFromPattern(this.kernel.domain);
320
+ if (d) hosts.add(d);
321
+ }
322
+ const patterns = Array.isArray(th) ? th : th ? [th] : [];
323
+ for (const p of patterns) {
324
+ if (typeof p !== "string") continue;
325
+ const v = viteAllowedHostFromPattern(p);
326
+ if (v) hosts.add(v);
327
+ else this.log(`trustedHosts « ${p} » non exprimable en allowedHosts Vite — cet hôte passera la barrière Nodefony mais Vite le refusera`, "WARNING");
328
+ }
329
+ if (publicOriginTemplate) {
330
+ const v = allowedHostPatternForTemplate(publicOriginTemplate);
331
+ if (v) hosts.add(v);
332
+ }
333
+ return hosts.size > 0 ? [...hosts] : void 0;
334
+ }
335
+ /** Regroupe les entries par famille d'isolation + remplit l'index inverse. */
336
+ groupEntriesByFamily() {
337
+ const groups = /* @__PURE__ */ new Map();
338
+ this.entryFamily.clear();
339
+ for (const entry of this.entries) {
340
+ const family = isolationGroup(entry.type);
341
+ this.entryFamily.set(entry.entryName, family);
342
+ const arr = groups.get(family);
343
+ if (arr) arr.push(entry);
344
+ else groups.set(family, [entry]);
345
+ }
346
+ return groups;
347
+ }
348
+ /**
349
+ * Démarre l'instance Vite d'une famille sur un port dédié. Enregistre le
350
+ * supervisor + son template helper AVANT le `start()` (l'état dégradé reste
351
+ * observable même si le démarrage échoue → rendu propre, pas d'exception).
352
+ */
353
+ async startFamily(family, entries, port, ctx) {
354
+ const r = this.cfg.resilience ?? {};
355
+ const supervisor = new ViteProcessSupervisor({
356
+ devHost: this.cfg.devHost,
357
+ devPort: port,
358
+ publicOriginTemplate: ctx.publicOriginTemplate,
359
+ allowedHosts: ctx.allowedHosts,
360
+ startupTimeoutMs: this.cfg.startupTimeoutMs,
361
+ pipeLogs: this.cfg.pipeViteLogs,
362
+ cwd: entries[0].root,
363
+ backendOrigin: ctx.backendOrigin,
364
+ https: ctx.https,
365
+ nodeEnv: ctx.nodeEnv,
366
+ extraEnv: ctx.extraEnv,
367
+ autoRestart: r.autoRestart,
368
+ maxRestarts: r.maxRestarts,
369
+ restartBackoffBaseMs: r.restartBackoffBaseMs,
370
+ restartBackoffMaxMs: r.restartBackoffMaxMs,
371
+ healthCheckIntervalMs: r.healthCheckIntervalMs,
372
+ healthCheckFailureThreshold: r.healthCheckFailureThreshold,
373
+ healthCheckTimeoutMs: r.healthCheckTimeoutMs,
374
+ portRetryAttempts: r.portRetryAttempts,
375
+ logger: {
376
+ info: (m) => this.log(`[${family}] ${m}`, "INFO"),
377
+ error: (m) => this.log(`[${family}] ${m}`, "ERROR"),
378
+ debug: (m) => this.log(`[${family}] ${m}`, "DEBUG")
379
+ }
380
+ });
381
+ this.supervisors.set(family, supervisor);
382
+ this.templateHelpers.set(family, new TemplateHelper(supervisor, "development"));
383
+ const cfg = await this.builder.buildViteConfig([...entries], "development");
384
+ await supervisor.start(entries, cfg);
385
+ this.log(`vite [${family}] ready on ${supervisor.status().host}:${supervisor.status().port}`, "INFO");
386
+ }
387
+ /**
388
+ * Câblage prod (idempotent) : monte chaque `outDir` sur son `publicPath`
389
+ * auprès du serveur statique `server-static` (résolu par nom — pas d'import
390
+ * http) et crée le helper prod (lecture manifests). No-op si pas d'entrée.
391
+ *
392
+ * Une entrée SANS build servirait une page blanche : jamais en silence.
393
+ * Cas nominal cloud-native : le build est fait à l'image (`npm run build`,
394
+ * qui chaîne `frontend:build` dans les apps générées) → manifest présent,
395
+ * zéro travail ici. Cas « prod essayée sur le poste » (devDeps installées →
396
+ * vite résolvable) : build one-shot au boot — idempotent, et il supprime
397
+ * l'écran blanc qui perd l'utilisateur, surtout en `--detach`. Sans vite
398
+ * (image runtime sans devDependencies) : impossible de réparer ici → on
399
+ * NOMME l'entrée, le manifest attendu et le geste, en ERROR.
400
+ * Cf project_resilience_no_silent_degradation (fail-soft dispo, fail-loud
401
+ * dégradation — tout fallback annoncé).
402
+ */
403
+ async setupProd() {
404
+ if (this.prodHelper) return;
405
+ if (this.entries.length === 0) {
406
+ this.log("no frontend entries declared — prod static not mounted", "INFO");
407
+ return;
408
+ }
409
+ const unbuilt = this.entries.filter((e) => !fs.existsSync(path.join(e.outDir, ".vite", "manifest.json")));
410
+ if (unbuilt.length > 0) {
411
+ const names = unbuilt.map((e) => e.entryName).join(", ");
412
+ try {
413
+ this.log(`entrée(s) frontend sans build (${names}) — construction au boot (one-shot). En production réelle, builde à l'image : npm run build.`, "WARNING");
414
+ const r = await this.build();
415
+ if (r.failures.length > 0) this.log(`build front en ÉCHEC au boot (${r.failures.map((f) => f.entryName).join(", ")}) — ces pages seront servies SANS interface`, "ERROR");
416
+ } catch (e) {
417
+ this.log(`entrée(s) frontend sans build (${names}) et vite indisponible ici — les pages seront servies SANS interface (blanches). Builde AVANT de lancer : npm run build (ou nodefony frontend:build), puis redémarre. Si vite manque aussi en développement, il n'est pas une dépendance de ce module : installe-le côté application (npm i -D vite, plus le plugin de ton framework). Détail : ${e instanceof Error ? e.message : String(e)}`, "ERROR");
418
+ }
419
+ }
420
+ const stat = this.container?.get?.("server-static");
421
+ if (stat?.addMount) for (const e of this.entries) {
422
+ stat.addMount(e.publicPath, e.outDir);
423
+ this.log(`prod static mount ${e.publicPath} → ${e.outDir}`, "INFO");
424
+ }
425
+ else this.log("server-static service unavailable — assets won't be served by Nodefony (expecting a frontal proxy)", "WARNING");
426
+ this.prodHelper = new TemplateHelper(null, "production", this.entries, this.assetBase);
427
+ this.fire("frontend:ready", this.status());
428
+ }
429
+ async stopDev() {
430
+ if (this.supervisors.size === 0) return;
431
+ await Promise.allSettled([...this.supervisors.values()].map((s) => s.stop()));
432
+ this.supervisors.clear();
433
+ this.templateHelpers.clear();
434
+ this.entryFamily.clear();
435
+ this.plannedPortBlocks = null;
436
+ (this.container?.get?.("firewall"))?.unregisterCspOrigins?.("frontend");
437
+ this.fire("frontend:stopped");
438
+ }
439
+ /**
440
+ * Build production — `vite.build()` **par entry** (chaque bundle a son propre
441
+ * `root`/`outDir`/`base`/`manifest` : multi-module + isolation Angular).
442
+ *
443
+ * Idempotent : une entrée dont le `manifest.json` est plus récent que ses
444
+ * sources est **ignorée** (`skipped`) — relance prod console rapide. `force`
445
+ * rebuild tout. Les échecs sont **collectés** (un bundle KO n'arrête pas les
446
+ * autres) et remontés dans `failures` → la commande CLI casse l'exit code.
447
+ *
448
+ * ⚠️ **`NODE_ENV` est posé le temps du build, et restauré.** Vite dérive son
449
+ * `isProduction` de `process.env.NODE_ENV`, qui **prime sur le `mode`** de la
450
+ * configuration : une commande CLI, dont le kernel démarre en développement,
451
+ * produisait donc un bundle de DÉVELOPPEMENT malgré `mode: "production"` —
452
+ * `import.meta.env.DEV` vrai chez l'utilisateur final (tout code gardé par ce
453
+ * drapeau s'exécutait en production), et les messages d'aide de Vue publiés.
454
+ * La restauration n'est pas une précaution de style : `build()` est aussi
455
+ * appelé par `setupProd()`, et laisser la variable retournée marquerait un
456
+ * process de développement comme production pour le reste de sa vie.
457
+ *
458
+ * @param opts.force ignore le cache de fraîcheur (rebuild systématique).
459
+ */
460
+ async build(opts) {
461
+ if (this.entries.length === 0) throw new FrontendNoEntriesError();
462
+ const vite = await import("vite");
463
+ const result = {
464
+ built: [],
465
+ skipped: [],
466
+ failures: []
467
+ };
468
+ const nodeEnvBefore = process.env.NODE_ENV;
469
+ process.env.NODE_ENV = "production";
470
+ try {
471
+ return await this.#buildEntries(vite, result, opts);
472
+ } finally {
473
+ if (nodeEnvBefore === void 0) delete process.env.NODE_ENV;
474
+ else process.env.NODE_ENV = nodeEnvBefore;
475
+ }
476
+ }
477
+ /**
478
+ * La boucle de build proprement dite — extraite pour que `build()` ne porte
479
+ * que la garde d'environnement, et que le `finally` de restauration couvre
480
+ * tous les chemins de sortie sans indenter cent lignes.
481
+ */
482
+ async #buildEntries(vite, result, opts) {
483
+ for (const entry of this.entries) {
484
+ if (!opts?.force && this.isBuildFresh(entry)) {
485
+ result.skipped.push(entry.entryName);
486
+ this.log(`build skip "${entry.entryName}" (à jour — --force pour forcer)`, "INFO");
487
+ continue;
488
+ }
489
+ try {
490
+ const cfg = await this.builder.buildViteConfig([entry], "production", this.assetBase);
491
+ await vite.build(cfg);
492
+ result.built.push(entry.entryName);
493
+ this.log(`build ok "${entry.entryName}" → ${entry.outDir}`, "INFO");
494
+ } catch (e) {
495
+ const message = e instanceof Error ? e.message : String(e);
496
+ result.failures.push({
497
+ entryName: entry.entryName,
498
+ message
499
+ });
500
+ this.log(`build FAILED "${entry.entryName}": ${message}`, "ERROR");
501
+ }
502
+ }
503
+ this.log(`frontend build: ${result.built.length} built, ${result.skipped.length} skipped, ${result.failures.length} failed`, result.failures.length ? "WARNING" : "INFO");
504
+ return result;
505
+ }
506
+ /**
507
+ * Une entrée est « fraîche » si son `manifest.json` existe ET qu'aucun fichier
508
+ * source (sous `root`, hors `node_modules`/`outDir`/`.vite`) n'est plus récent.
509
+ * Scan disque borné (dossier front petit) — évite un rebuild Vite inutile.
510
+ */
511
+ isBuildFresh(entry) {
512
+ let manifestMtime;
513
+ try {
514
+ manifestMtime = fs.statSync(path.join(entry.outDir, ".vite", "manifest.json")).mtimeMs;
515
+ } catch {
516
+ return false;
517
+ }
518
+ return this.newestSourceMtime(entry.root, entry.outDir) <= manifestMtime;
519
+ }
520
+ /** Mtime du fichier le plus récent sous `dir` (récursif borné). */
521
+ newestSourceMtime(dir, outDir) {
522
+ let newest = 0;
523
+ const walk = (d) => {
524
+ let items;
525
+ try {
526
+ items = fs.readdirSync(d, { withFileTypes: true });
527
+ } catch {
528
+ return;
529
+ }
530
+ for (const it of items) {
531
+ if (it.name === "node_modules" || it.name === ".vite") continue;
532
+ const full = path.join(d, it.name);
533
+ if (full === outDir) continue;
534
+ if (it.isDirectory()) walk(full);
535
+ else try {
536
+ const m = fs.statSync(full).mtimeMs;
537
+ if (m > newest) newest = m;
538
+ } catch {}
539
+ }
540
+ };
541
+ walk(dir);
542
+ return newest;
543
+ }
544
+ /**
545
+ * Document HTML complet pour une entrée — lit l'`index.html` du module
546
+ * (le dev y met meta/polices/scripts externes) + injecte les tags Nodefony.
547
+ * Le controller renvoie : `this.render(svc.renderDocument("x", this.context.cspNonce))`.
548
+ * @param nonce nonce CSP de la requête (`Context.cspNonce`) — propagé aux `<script>`.
549
+ */
550
+ renderDocument(entryName, nonce, requestHost) {
551
+ if (this.prodHelper) return this.prodHelper.renderDocument(entryName, nonce);
552
+ const family = this.entryFamily.get(entryName);
553
+ const helper = family ? this.templateHelpers.get(family) : void 0;
554
+ if (!helper) return `<!-- @nodefony/frontend: helper not initialized for "${entryName}" -->`;
555
+ return helper.renderDocument(entryName, nonce, this.derivableHost(requestHost));
556
+ }
557
+ renderTags(entryName, nonce, requestHost) {
558
+ if (this.prodHelper) return this.prodHelper.renderTags(entryName, nonce);
559
+ const family = this.entryFamily.get(entryName);
560
+ const helper = family ? this.templateHelpers.get(family) : void 0;
561
+ if (!helper) return `<!-- @nodefony/frontend: helper not initialized for "${entryName}" -->`;
562
+ return helper.renderTags(entryName, nonce, this.derivableHost(requestHost));
563
+ }
564
+ /**
565
+ * Le `Host` de la requête peut-il servir à dériver l'origine des assets ?
566
+ *
567
+ * Trois conditions, dans cet ordre — chacune protège un cas RÉEL :
568
+ * 1. **origine non épinglée** : un `frontend.publicOrigin` explicite (ou une
569
+ * plateforme de dev déporté détectée) est une décision de l'auteur, elle
570
+ * gagne toujours sur une déduction ;
571
+ * 2. **barrière `trustedHosts` franchie** : le `Host` est une donnée
572
+ * CLIENTE. Sans ce filtre, un `Host` forgé ferait émettre des
573
+ * `<script src="https://attaquant:5173/…">` dans une page de dev ;
574
+ * 3. **barrière non déléguée** (`trustedHosts !== true`) : le bypass total
575
+ * ne dit plus rien de la légitimité d'un nom, et surtout le CSP émis par
576
+ * le firewall (`#viteCspFragment`) ne couvre alors QUE loopback +
577
+ * domaine canonique — dériver ailleurs produirait une page dont les
578
+ * scripts sont bloqués. Les deux listes doivent rester la même liste.
579
+ *
580
+ * @returns le nom d'hôte à employer, ou `undefined` pour garder l'origine
581
+ * résolue au démarrage (comportement d'avant la dérivation).
582
+ */
583
+ derivableHost(requestHost) {
584
+ if (!requestHost || this.originPinned) return void 0;
585
+ const httpKernel = this.container?.get?.("HttpKernel");
586
+ if (!httpKernel || httpKernel.trustedHosts === true) return void 0;
587
+ return httpKernel.isTrustedHostname?.(requestHost) === true ? requestHost : void 0;
588
+ }
589
+ /**
590
+ * Déclare les origines Vite dev au firewall `@nodefony/security` (résolution PAR
591
+ * NOM = anti-cycle, comme `setupProd`/`server-static`). Le firewall émet alors UN
592
+ * seul CSP (nonce + origines mergées) → remplace le hack `setHeader` des controllers.
593
+ * No-op si security absent (app sans firewall).
594
+ */
595
+ #registerCsp() {
596
+ (this.container?.get?.("firewall"))?.registerCspOrigins?.("frontend", this.#viteCspFragment());
597
+ }
598
+ /**
599
+ * Fragment CSP des besoins Vite DEV. Deux exigences :
600
+ * 1. **`'self'` dans CHAQUE directive** : `connect-src`/`style-src`/`img-src`/
601
+ * `font-src` n'héritent PAS de `default-src` → sans `'self'`, tout le
602
+ * same-origin (fetch API, styles, images, fonts) serait bloqué.
603
+ * 2. **Origines Vite sur tous les hosts de dev** : loopback + `kernel.domain`
604
+ * + la liste `trustedHosts` du module http (vhosts comme `nodefony.com`) ×
605
+ * ports Vite. Sans ça, accéder via un vhost bloque les ressources Vite.
606
+ * Tokens : `'unsafe-eval'` (React Fast Refresh — le nonce ne couvre PAS l'eval)
607
+ * et `'unsafe-inline'` style (styles injectés par Vite). PAS de `'unsafe-inline'`
608
+ * script : le preamble inline est NONCÉ. Mergé par le firewall (jamais en prod :
609
+ * `startDev` ne tourne pas en production → CSP strict same-origin).
610
+ */
611
+ #viteCspFragment() {
612
+ const scheme = this.cfg.https ? "https" : "http";
613
+ const wsScheme = this.cfg.https ? "wss" : "ws";
614
+ const ports = this.cspPorts();
615
+ const hosts = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
616
+ if (this.kernel?.domain) hosts.add(this.kernel.domain);
617
+ const th = (this.container?.get?.("HttpKernel"))?.trustedHosts;
618
+ if (typeof th === "string") hosts.add(th);
619
+ else if (Array.isArray(th)) for (const h of th) hosts.add(h);
620
+ const httpSrc = [];
621
+ const wsSrc = [];
622
+ for (const h of hosts) for (const p of ports) {
623
+ httpSrc.push(`${scheme}://${h}:${p}`);
624
+ wsSrc.push(`${wsScheme}://${h}:${p}`);
625
+ }
626
+ for (const s of this.supervisors.values()) {
627
+ const o = s.status().origin;
628
+ if (!o) continue;
629
+ if (!httpSrc.includes(o)) httpSrc.push(o);
630
+ const w = o.replace(/^http/, "ws");
631
+ if (!wsSrc.includes(w)) wsSrc.push(w);
632
+ }
633
+ return {
634
+ "script-src": [
635
+ "'self'",
636
+ "'unsafe-eval'",
637
+ ...httpSrc
638
+ ],
639
+ "style-src": [
640
+ "'self'",
641
+ "'unsafe-inline'",
642
+ ...httpSrc
643
+ ],
644
+ "worker-src": ["'self'", "blob:"],
645
+ "img-src": [
646
+ "'self'",
647
+ "data:",
648
+ "blob:",
649
+ "https://www.gravatar.com",
650
+ "https://*.googleusercontent.com",
651
+ "https://avatars.githubusercontent.com",
652
+ ...httpSrc
653
+ ],
654
+ "font-src": [
655
+ "'self'",
656
+ "data:",
657
+ ...httpSrc
658
+ ],
659
+ "connect-src": [
660
+ "'self'",
661
+ "blob:",
662
+ "data:",
663
+ ...httpSrc,
664
+ ...wsSrc
665
+ ]
666
+ };
667
+ }
668
+ /**
669
+ * Ports Vite à déclarer au CSP, famille par famille : le BLOC entier tant que
670
+ * l'instance ne sert pas, son port RÉEL dès qu'elle sert.
671
+ *
672
+ * Pourquoi le bloc avant : le CSP part AVEC la page et ne se renégocie jamais.
673
+ * Une page servie avant que Vite ait résolu son port doit déjà porter le port
674
+ * qu'il prendra, sinon son socket de rechargement à chaud est refusé pour
675
+ * toute la durée de la page.
676
+ *
677
+ * Pourquoi le port seul après : la plage est le prix d'une incertitude, elle
678
+ * ne doit pas lui survivre. Mesuré sur ce dépôt (3 familles × 4 hôtes de
679
+ * confiance), garder les blocs porte l'en-tête CSP à ~7,9 Ko sur CHAQUE
680
+ * réponse, contre ~2,3 Ko une fois les ports connus — au bord des 8 Ko que
681
+ * refusent beaucoup de relais.
682
+ *
683
+ * Développement seulement : `startDev` ne tourne pas en production. La
684
+ * garantie reste une liste d'origines nommées — jamais un `ws:` sans hôte,
685
+ * qui la supprimerait au lieu de corriger le symptôme.
686
+ *
687
+ * @returns ports en chaîne, dédupliqués ; repli `devPort` si rien n'est connu.
688
+ */
689
+ cspPorts() {
690
+ const ports = /* @__PURE__ */ new Set();
691
+ for (const [family, block] of this.plannedPortBlocks ?? []) {
692
+ const st = this.supervisors.get(family)?.status();
693
+ if ((st?.state === "ready" || st?.state === "compiling") && st?.port) ports.add(String(st.port));
694
+ else for (const p of block) ports.add(String(p));
695
+ }
696
+ for (const [family, sup] of this.supervisors) {
697
+ if (this.plannedPortBlocks?.has(family)) continue;
698
+ const st = sup.status();
699
+ if (st.port) ports.add(String(st.port));
700
+ }
701
+ if (ports.size === 0) ports.add(String(this.cfg.devPort));
702
+ return ports;
703
+ }
704
+ };
705
+ FrontendService = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], FrontendService);
706
+ var FrontendService_default = FrontendService;
707
+ //#endregion
708
+ export { FrontendService_default as default };