@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,589 @@
1
+ import { FrontendSupervisorStartError } from "../src/errors/FrontendError.js";
2
+ import { ViteConfigGenerator } from "./ViteConfigGenerator.js";
3
+ import { browserReachableHost, resolveOriginTemplate } from "../src/remoteDev.js";
4
+ import { createRequire } from "node:module";
5
+ import { signalProcessGroup } from "nodefony";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import http from "node:http";
10
+ import https from "node:https";
11
+ //#region nodefony/service/ViteProcessSupervisor.ts
12
+ /**
13
+ * Résout le binaire Vite **une seule fois** (mis en cache module-level).
14
+ *
15
+ * Lancer le VRAI `node vite.js` en direct — au lieu du shim `npx vite` — évite le
16
+ * process intermédiaire npm : 1 process au lieu de 2, et surtout `SIGINT`/`SIGKILL`
17
+ * atteignent **directement** Vite (`npx`/`npm exec` relaie mal les signaux → Vite
18
+ * orphelin → `EADDRINUSE` au restart).
19
+ *
20
+ * ⚠️ On passe par `vite/package.json` + son champ `bin` : la map `exports` de Vite
21
+ * **n'expose pas** `./bin/vite.js` → `require.resolve("vite/bin/vite.js")` throw
22
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED`. `package.json`, lui, est exporté → résolvable.
23
+ *
24
+ * `null` = résolution impossible → fallback `npx` (jamais cassé = résilient).
25
+ * `undefined` = pas encore tenté.
26
+ */
27
+ let _viteBin;
28
+ function resolveViteBin() {
29
+ if (_viteBin !== void 0) return _viteBin;
30
+ try {
31
+ const pkgPath = createRequire(import.meta.url).resolve("vite/package.json");
32
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
33
+ const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vite;
34
+ const abs = rel ? path.join(path.dirname(pkgPath), rel) : null;
35
+ _viteBin = abs && existsSync(abs) ? abs : null;
36
+ } catch {
37
+ _viteBin = null;
38
+ }
39
+ return _viteBin;
40
+ }
41
+ const DEFAULTS = {
42
+ autoRestart: true,
43
+ maxRestarts: 5,
44
+ restartBackoffBaseMs: 500,
45
+ restartBackoffMaxMs: 8e3,
46
+ healthCheckIntervalMs: 3e4,
47
+ healthCheckFailureThreshold: 3,
48
+ healthCheckTimeoutMs: 5e3,
49
+ portRetryAttempts: 3
50
+ };
51
+ /**
52
+ * Un texte (message d'erreur OU sortie brute de Vite) dénonce-t-il un port occupé ?
53
+ *
54
+ * **Source UNIQUE** de cette décision. Elle était dupliquée en deux regex qui ont
55
+ * divergé : l'une cherchait `port X is in use`, alors que Vite écrit
56
+ * `Port 5173 is ALREADY in use`. Résultat, le retry de port ne se déclenchait
57
+ * jamais et la seconde app perdait tout son frontend — un conflit de port pourtant
58
+ * parfaitement rattrapable. On tolère donc les deux formulations, et on ne
59
+ * l'écrit qu'ici (deux implémentations d'une même règle = dérive garantie).
60
+ */
61
+ function isPortInUseMessage(text) {
62
+ return /EADDRINUSE/i.test(text) || /address already in use/i.test(text) || /port\s+\d+\s+is\s+(?:already\s+)?in use/i.test(text);
63
+ }
64
+ /**
65
+ * Message d'échec d'un boot qui n'a jamais rendu la main dans le temps imparti —
66
+ * en y REPORTANT ce que vite avait déjà dit.
67
+ *
68
+ * Le conflit de port ne se voit que dans la sortie du child. Le rejet par
69
+ * échéance ne portait que la durée : un boot qui annonçait « Port 5173 is
70
+ * already in use » puis restait pendu (vite ne meurt pas toujours, et sur une
71
+ * application réelle le pré-bundling tient l'échéance) sortait sous le libellé
72
+ * `timeout after …`, que le détecteur de port occupé ne reconnaît pas. Le repli
73
+ * sur `port+1` existait et n'était jamais atteint — la sortie d'échec choisie
74
+ * décidait à elle seule si la 2ᵉ application aurait un frontend.
75
+ *
76
+ * Fonction PURE : la règle « ce qu'on a OBSERVÉ prime sur la façon dont on a
77
+ * échoué » se teste sans spawn, donc sans dépendre d'une course entre l'écriture
78
+ * de l'erreur et la mort du process.
79
+ *
80
+ * @param timeoutMs - échéance dépassée.
81
+ * @param observed - sortie brute accumulée du child (stdout + stderr).
82
+ * @returns message d'erreur, préfixé `EADDRINUSE:` si un port occupé est dénoncé.
83
+ */
84
+ function startupTimeoutMessage(timeoutMs, observed) {
85
+ const base = `timeout after ${timeoutMs}ms`;
86
+ return isPortInUseMessage(observed) ? `EADDRINUSE: ${base} — vite a annoncé un port occupé sans rendre la main` : base;
87
+ }
88
+ /**
89
+ * Superviseur Vite résilient — branche POC `poc/frontend-child`.
90
+ *
91
+ * Garanties :
92
+ * - Idempotent : appels concurrents à `start()` partagent la même promesse.
93
+ * - Auto-restart : crash inattendu → restart avec backoff exponentiel borné.
94
+ * - Port conflict : retry sur port+1 jusqu'au plafond `portRetryAttempts`.
95
+ * - Health check : ping périodique, kill+restart sur N échecs consécutifs.
96
+ * - Cleanup strict : listeners + timers tracés et libérés au `stop()`.
97
+ *
98
+ * Tous les listeners sur `child` sont supprimés explicitement avant qu'on
99
+ * laisse le child mourir (évite memory leak entre restarts).
100
+ */
101
+ var ViteProcessSupervisor = class {
102
+ opts;
103
+ cfg;
104
+ generator = new ViteConfigGenerator();
105
+ child = null;
106
+ state = "idle";
107
+ resolvedPort = null;
108
+ /** Origine publique du spawn courant (source unique des URLs — cf status). */
109
+ resolvedOrigin = null;
110
+ lastError = null;
111
+ entries = [];
112
+ configFilePath = null;
113
+ restartCount = 0;
114
+ healthFailures = 0;
115
+ healthCheckTimer = null;
116
+ restartTimer = null;
117
+ startPromise = null;
118
+ stopPromise = null;
119
+ willingShutdown = false;
120
+ /** Kill VOULU par le health check (recovery) : le prochain exit doit relancer. */
121
+ expectRestartKill = false;
122
+ /**
123
+ * Signal d'arrêt reçu par NOTRE process (Ctrl+C au groupe foreground, SIGTERM
124
+ * d'un orchestrateur) → tout exit de Vite est un ARRÊT, jamais un crash.
125
+ * Indispensable : Vite intercepte SIGINT et sort `code=130, signal=null` —
126
+ * indiscernable d'un crash côté exit event, et `willingShutdown` (posé par le
127
+ * stop du kernel) arrive APRÈS la mort de Vite (race sans IPC, vécu Ctrl+C :
128
+ * « vite restart #1 failed » en ERROR sur un arrêt normal).
129
+ */
130
+ markShutdown = () => {
131
+ this.willingShutdown = true;
132
+ };
133
+ /**
134
+ * Listeners attachés au child courant — drainés à chaque mort du child.
135
+ * Sans ça, le child gardé en référence (avant GC) accumule des handlers
136
+ * entre restarts → MaxListenersExceededWarning + leak.
137
+ */
138
+ childListeners = [];
139
+ constructor(opts) {
140
+ this.opts = opts;
141
+ this.cfg = {
142
+ autoRestart: opts.autoRestart ?? DEFAULTS.autoRestart,
143
+ maxRestarts: opts.maxRestarts ?? DEFAULTS.maxRestarts,
144
+ restartBackoffBaseMs: opts.restartBackoffBaseMs ?? DEFAULTS.restartBackoffBaseMs,
145
+ restartBackoffMaxMs: opts.restartBackoffMaxMs ?? DEFAULTS.restartBackoffMaxMs,
146
+ healthCheckIntervalMs: opts.healthCheckIntervalMs ?? DEFAULTS.healthCheckIntervalMs,
147
+ healthCheckFailureThreshold: opts.healthCheckFailureThreshold ?? DEFAULTS.healthCheckFailureThreshold,
148
+ healthCheckTimeoutMs: opts.healthCheckTimeoutMs ?? DEFAULTS.healthCheckTimeoutMs,
149
+ portRetryAttempts: opts.portRetryAttempts ?? DEFAULTS.portRetryAttempts
150
+ };
151
+ }
152
+ async start(entries, _viteConfigUnused) {
153
+ if (this.state === "ready") return;
154
+ if (this.startPromise) return this.startPromise;
155
+ if (this.state === "stopping") throw new FrontendSupervisorStartError("cannot start while stopping");
156
+ if (entries.length === 0) throw new FrontendSupervisorStartError("no entries");
157
+ this.entries = entries;
158
+ this.willingShutdown = false;
159
+ this.restartCount = 0;
160
+ this.lastError = null;
161
+ process.once("SIGINT", this.markShutdown);
162
+ process.once("SIGTERM", this.markShutdown);
163
+ this.startPromise = this.spawnWithPortRetry();
164
+ try {
165
+ await this.startPromise;
166
+ this.startHealthCheck();
167
+ } finally {
168
+ this.startPromise = null;
169
+ }
170
+ }
171
+ async stop() {
172
+ if (this.state === "idle" || this.state === "stopped") return;
173
+ if (this.stopPromise) return this.stopPromise;
174
+ this.stopPromise = this.doStop();
175
+ try {
176
+ await this.stopPromise;
177
+ } finally {
178
+ this.stopPromise = null;
179
+ }
180
+ }
181
+ status() {
182
+ return {
183
+ state: this.state,
184
+ host: this.opts.devHost,
185
+ origin: this.resolvedOrigin,
186
+ port: this.resolvedPort ?? this.opts.devPort,
187
+ pid: this.child?.pid ?? null,
188
+ lastError: this.lastError,
189
+ entries: this.entries,
190
+ https: !!this.opts.https,
191
+ restartCount: this.restartCount,
192
+ healthFailures: this.healthFailures,
193
+ portRetries: this.portRetries
194
+ };
195
+ }
196
+ /**
197
+ * Essaie de spawn Vite en variant le port si EADDRINUSE. Le port résolu
198
+ * est stocké dans `resolvedPort` (utilisé par status() + TemplateHelper).
199
+ */
200
+ async spawnWithPortRetry() {
201
+ const maxAttempts = this.cfg.portRetryAttempts;
202
+ let lastErr = null;
203
+ this.portRetries = 0;
204
+ for (let i = 0; i <= maxAttempts; i++) {
205
+ const port = this.opts.devPort + i;
206
+ try {
207
+ await this.attemptSpawn(port);
208
+ return;
209
+ } catch (e) {
210
+ lastErr = e instanceof Error ? e : new Error(String(e));
211
+ if (this.isPortInUseError(lastErr) && i < maxAttempts) {
212
+ this.opts.logger.info(`port ${port} unavailable — retrying on ${port + 1}`);
213
+ this.portRetries += 1;
214
+ continue;
215
+ }
216
+ throw lastErr;
217
+ }
218
+ }
219
+ throw lastErr ?? new FrontendSupervisorStartError("port retry exhausted");
220
+ }
221
+ /** Replis de port du DERNIER démarrage — cf `IViteSupervisorStatus.portRetries`. */
222
+ portRetries = 0;
223
+ isPortInUseError(e) {
224
+ return isPortInUseMessage(e.message);
225
+ }
226
+ /** Spawn Vite sur un port donné et attend le ready. */
227
+ async attemptSpawn(port) {
228
+ this.state = "starting";
229
+ this.cleanupChildListeners();
230
+ const moduleRoot = this.entries[0].root;
231
+ this.configFilePath = path.resolve(moduleRoot, "vite.config.generated.mjs");
232
+ const scheme = this.opts.https ? "https" : "http";
233
+ const tpl = this.opts.publicOriginTemplate;
234
+ const resolved = tpl ? resolveOriginTemplate(tpl, port) : null;
235
+ if (tpl && !resolved) this.opts.logger.error(`publicOrigin invalide (« ${tpl} ») — attendu scheme://host[:port|:{port}] ; origine locale dérivée utilisée à la place`);
236
+ if (resolved && !tpl.includes("{port}") && port !== this.opts.devPort) this.opts.logger.error(`publicOrigin figée (« ${tpl} ») mais Vite écoute sur ${port} (≠ ${this.opts.devPort}) — ajouter {port} au template ou libérer le port d'origine`);
237
+ const viteOrigin = resolved?.origin ?? `${scheme}://${browserReachableHost(this.opts.devHost)}:${port}`;
238
+ this.resolvedOrigin = viteOrigin;
239
+ const content = this.generator.toMjs(this.entries, "development", {
240
+ backendOrigin: this.opts.backendOrigin,
241
+ viteOrigin,
242
+ https: this.opts.https,
243
+ allowedHosts: this.opts.allowedHosts,
244
+ hmr: resolved?.hmr
245
+ });
246
+ writeFileSync(this.configFilePath, content, "utf8");
247
+ this.opts.logger.debug?.(`vite config written: ${this.configFilePath}`);
248
+ const args = [
249
+ "vite",
250
+ "--config",
251
+ this.configFilePath,
252
+ "--host",
253
+ this.opts.devHost,
254
+ "--port",
255
+ String(port),
256
+ ...this.opts.nodeEnv ? ["--mode", this.opts.nodeEnv] : []
257
+ ];
258
+ const viteBin = resolveViteBin();
259
+ if (!viteBin && process.platform === "win32") {
260
+ this.state = "errored";
261
+ this.lastError = "vite binary unresolved (win32)";
262
+ throw new FrontendSupervisorStartError("binaire vite irrésoluble et fallback npx non portable sous Windows — installer vite en devDependency de l'application");
263
+ }
264
+ const spawnCmd = viteBin ? process.execPath : "npx";
265
+ const spawnArgs = viteBin ? [viteBin, ...args.slice(1)] : args;
266
+ const psLabel = `nodefony-vite[${this.entries.map((e) => e.entryName).join("+")}]`;
267
+ this.opts.logger.debug?.(viteBin ? `vite spawn direct (node ${viteBin})` : "vite spawn via npx (fallback — résolution directe indisponible)");
268
+ try {
269
+ this.child = spawn(spawnCmd, spawnArgs, {
270
+ cwd: this.opts.cwd,
271
+ argv0: psLabel,
272
+ stdio: [
273
+ "ignore",
274
+ "pipe",
275
+ "pipe"
276
+ ],
277
+ detached: false,
278
+ env: {
279
+ ...process.env,
280
+ ...this.opts.nodeEnv ? { NODE_ENV: this.opts.nodeEnv } : {},
281
+ ...this.opts.extraEnv,
282
+ FORCE_COLOR: "0",
283
+ NO_COLOR: "1"
284
+ }
285
+ });
286
+ } catch (e) {
287
+ this.state = "errored";
288
+ this.lastError = e instanceof Error ? e.message : String(e);
289
+ throw new FrontendSupervisorStartError("spawn failed", e);
290
+ }
291
+ await this.waitReady(port);
292
+ this.opts.logger.debug?.(`vite ready on port ${port}`);
293
+ }
294
+ async waitReady(_expectedPort) {
295
+ await new Promise((resolve, reject) => {
296
+ const child = this.child;
297
+ if (!child) {
298
+ reject(new FrontendSupervisorStartError("no child"));
299
+ return;
300
+ }
301
+ let resolved = false;
302
+ const timeout = setTimeout(() => {
303
+ if (resolved) return;
304
+ resolved = true;
305
+ this.state = "errored";
306
+ if (child.exitCode === null && child.signalCode === null) this.signalTree(child, "SIGKILL");
307
+ const msg = startupTimeoutMessage(this.opts.startupTimeoutMs, buffer);
308
+ this.lastError = msg;
309
+ reject(new FrontendSupervisorStartError(msg));
310
+ }, this.opts.startupTimeoutMs);
311
+ const localRe = /Local:\s+https?:\/\/([^:\s]+):(\d+)/;
312
+ const ansiRe = /\x1b\[[0-9;]*m/g;
313
+ const viteOwnPrefixRe = /^\d{1,2}:\d{2}:\d{2}\s+\[vite\]\s*|^\[vite\]\s*/;
314
+ let buffer = "";
315
+ const pipeClean = (raw, level) => {
316
+ if (!this.opts.pipeLogs) return;
317
+ for (const line of raw.split(/\r?\n/)) {
318
+ const stripped = line.replace(ansiRe, "").replace(viteOwnPrefixRe, "").trim();
319
+ if (!stripped) continue;
320
+ if (level === "error") this.opts.logger.error(`[vite] ${stripped}`);
321
+ else this.opts.logger.info(`[vite] ${stripped}`);
322
+ }
323
+ };
324
+ const onStdout = (chunk) => {
325
+ const txt = chunk.toString();
326
+ pipeClean(txt, "info");
327
+ if (resolved) return;
328
+ buffer += txt.replace(ansiRe, "");
329
+ const m = buffer.match(localRe);
330
+ if (m) {
331
+ this.resolvedPort = parseInt(m[2], 10);
332
+ this.state = "ready";
333
+ this.healthFailures = 0;
334
+ resolved = true;
335
+ buffer = "";
336
+ clearTimeout(timeout);
337
+ resolve();
338
+ }
339
+ };
340
+ const onStderr = (chunk) => {
341
+ const txt = chunk.toString();
342
+ pipeClean(txt, "error");
343
+ if (resolved) return;
344
+ buffer += txt.replace(ansiRe, "");
345
+ const m = buffer.match(localRe);
346
+ if (m) {
347
+ this.resolvedPort = parseInt(m[2], 10);
348
+ this.state = "ready";
349
+ this.healthFailures = 0;
350
+ resolved = true;
351
+ buffer = "";
352
+ clearTimeout(timeout);
353
+ resolve();
354
+ }
355
+ };
356
+ const onExit = (code, signal) => {
357
+ if (!resolved) {
358
+ resolved = true;
359
+ clearTimeout(timeout);
360
+ if (this.willingShutdown) {
361
+ this.state = "stopped";
362
+ this.lastError = null;
363
+ reject(new FrontendSupervisorStartError("arrêt demandé pendant le boot de vite (shutdown, pas une panne)"));
364
+ return;
365
+ }
366
+ const msg = `vite exited (code=${code}, signal=${signal}) before ready`;
367
+ this.state = "errored";
368
+ this.lastError = msg;
369
+ if (isPortInUseMessage(buffer)) reject(new FrontendSupervisorStartError("EADDRINUSE: " + msg));
370
+ else reject(new FrontendSupervisorStartError(msg));
371
+ }
372
+ };
373
+ const onError = (err) => {
374
+ if (resolved) return;
375
+ resolved = true;
376
+ clearTimeout(timeout);
377
+ this.state = "errored";
378
+ this.lastError = err.message;
379
+ reject(new FrontendSupervisorStartError(err.message, err));
380
+ };
381
+ this.trackListener(child.stdout, "data", onStdout);
382
+ this.trackListener(child.stderr, "data", onStderr);
383
+ this.trackListener(child, "exit", onExit);
384
+ this.trackListener(child, "error", onError);
385
+ });
386
+ this.attachRuntimeExitHandler();
387
+ }
388
+ /**
389
+ * Après que Vite est ready, on attache un handler qui distingue :
390
+ * - shutdown volontaire (willingShutdown=true → state=stopped, no restart)
391
+ * - crash inattendu (state=ready au moment du exit → scheduleRestart)
392
+ */
393
+ attachRuntimeExitHandler() {
394
+ if (!this.child) return;
395
+ const onRuntimeExit = (code, signal) => {
396
+ this.cleanupChildListeners();
397
+ this.opts.logger.info(`[vite] exited (code=${code}, signal=${signal})`);
398
+ if (this.willingShutdown) {
399
+ this.state = "stopped";
400
+ this.child = null;
401
+ return;
402
+ }
403
+ if (this.expectRestartKill) this.expectRestartKill = false;
404
+ else if (signal === "SIGINT" || signal === "SIGTERM" || signal === "SIGHUP") {
405
+ this.state = "stopped";
406
+ this.child = null;
407
+ this.opts.logger.info(`[vite] arrêté (${signal} — signal d'arrêt) : pas de relance`);
408
+ return;
409
+ }
410
+ this.state = "crashed";
411
+ this.lastError = `crashed (code=${code}, signal=${signal})`;
412
+ this.child = null;
413
+ if (this.cfg.autoRestart) this.scheduleRestart();
414
+ else this.state = "errored";
415
+ };
416
+ this.trackListener(this.child, "exit", onRuntimeExit);
417
+ }
418
+ scheduleRestart() {
419
+ if (this.restartCount >= this.cfg.maxRestarts) {
420
+ this.state = "errored";
421
+ this.lastError = `max restarts (${this.cfg.maxRestarts}) reached`;
422
+ this.opts.logger.error(this.lastError);
423
+ this.stopHealthCheck();
424
+ return;
425
+ }
426
+ const delay = Math.min(this.cfg.restartBackoffBaseMs * 2 ** this.restartCount, this.cfg.restartBackoffMaxMs);
427
+ this.restartCount++;
428
+ this.state = "restarting";
429
+ this.opts.logger.info(`vite restart #${this.restartCount}/${this.cfg.maxRestarts} in ${delay}ms`);
430
+ this.stopHealthCheck();
431
+ this.restartTimer = setTimeout(() => {
432
+ this.restartTimer = null;
433
+ if (this.willingShutdown) return;
434
+ this.spawnWithPortRetry().then(() => {
435
+ this.opts.logger.info(`vite restart #${this.restartCount} succeeded`);
436
+ this.startHealthCheck();
437
+ }).catch((e) => {
438
+ this.opts.logger.error(`vite restart #${this.restartCount} failed: ${e?.message ?? e}`);
439
+ this.state = "crashed";
440
+ this.scheduleRestart();
441
+ });
442
+ }, delay);
443
+ }
444
+ startHealthCheck() {
445
+ if (this.cfg.healthCheckIntervalMs <= 0) return;
446
+ this.stopHealthCheck();
447
+ this.healthFailures = 0;
448
+ this.healthCheckTimer = setInterval(() => {
449
+ this.pingVite().then(() => {
450
+ if (this.healthFailures > 0) this.opts.logger.info(`vite healthcheck recovered after ${this.healthFailures} failure(s)`);
451
+ this.healthFailures = 0;
452
+ if (this.restartCount > 0 && this.state === "ready") {
453
+ this.opts.logger.info(`vite stable après restart — budget de restarts réarmé (était ${this.restartCount}/${this.cfg.maxRestarts})`);
454
+ this.restartCount = 0;
455
+ }
456
+ }).catch((e) => {
457
+ this.healthFailures++;
458
+ this.opts.logger.error(`vite healthcheck failed (${this.healthFailures}/${this.cfg.healthCheckFailureThreshold}): ${e?.message ?? e}`);
459
+ if (this.healthFailures >= this.cfg.healthCheckFailureThreshold && this.state === "ready") {
460
+ this.opts.logger.error(`vite unhealthy — killing child to trigger restart`);
461
+ this.expectRestartKill = true;
462
+ this.killChild();
463
+ }
464
+ });
465
+ }, this.cfg.healthCheckIntervalMs);
466
+ }
467
+ stopHealthCheck() {
468
+ if (this.healthCheckTimer) {
469
+ clearInterval(this.healthCheckTimer);
470
+ this.healthCheckTimer = null;
471
+ }
472
+ }
473
+ /**
474
+ * Ping HTTP(S) GET sur la racine de Vite. Considère réussi si réception de
475
+ * headers (même 4xx — Vite ne renvoie pas forcément 200 à `/`).
476
+ */
477
+ pingVite() {
478
+ return new Promise((resolve, reject) => {
479
+ const port = this.resolvedPort ?? this.opts.devPort;
480
+ const req = (this.opts.https ? https : http).request({
481
+ hostname: browserReachableHost(this.opts.devHost),
482
+ port,
483
+ path: "/",
484
+ method: "GET",
485
+ rejectUnauthorized: false,
486
+ timeout: this.cfg.healthCheckTimeoutMs
487
+ }, (res) => {
488
+ res.resume();
489
+ resolve();
490
+ });
491
+ req.on("error", (e) => reject(e));
492
+ req.on("timeout", () => {
493
+ req.destroy();
494
+ reject(/* @__PURE__ */ new Error("healthcheck timeout"));
495
+ });
496
+ req.end();
497
+ });
498
+ }
499
+ /** Stop volontaire — n'enclenche PAS l'auto-restart. */
500
+ async doStop() {
501
+ this.willingShutdown = true;
502
+ process.removeListener("SIGINT", this.markShutdown);
503
+ process.removeListener("SIGTERM", this.markShutdown);
504
+ this.state = "stopping";
505
+ if (this.restartTimer) {
506
+ clearTimeout(this.restartTimer);
507
+ this.restartTimer = null;
508
+ }
509
+ this.stopHealthCheck();
510
+ const child = this.child;
511
+ if (!child) {
512
+ this.state = "stopped";
513
+ return;
514
+ }
515
+ return new Promise((resolve) => {
516
+ let sigKillTimer = null;
517
+ let settled = false;
518
+ const done = () => {
519
+ if (settled) return;
520
+ settled = true;
521
+ child.removeListener("exit", done);
522
+ this.cleanupChildListeners();
523
+ this.child = null;
524
+ this.state = "stopped";
525
+ if (sigKillTimer) clearTimeout(sigKillTimer);
526
+ resolve();
527
+ };
528
+ child.once("exit", done);
529
+ if (this.signalTree(child, "SIGINT") === "gone") {
530
+ done();
531
+ return;
532
+ }
533
+ sigKillTimer = setTimeout(() => {
534
+ const c = this.child;
535
+ if (c && c.exitCode === null && c.signalCode === null) this.signalTree(c, "SIGKILL");
536
+ }, 3e3);
537
+ });
538
+ }
539
+ /** Force kill du child (pour les health checks failing). Le exit handler gère le restart. */
540
+ killChild() {
541
+ if (!this.child) return;
542
+ this.signalTree(this.child, "SIGTERM");
543
+ }
544
+ /**
545
+ * Envoie un signal à l'ARBRE de Vite — lui et ses propres enfants, à commencer par
546
+ * le service esbuild que Vite lance pour la pré-optimisation des dépendances.
547
+ *
548
+ * `child.kill()` n'atteint que Vite. Sous POSIX cela suffit en pratique : Vite n'est
549
+ * pas leader de son groupe (`detached: false`, voulu — un Ctrl+C terminal doit
550
+ * l'atteindre), donc le comportement ici est inchangé, et l'arbre est de toute façon
551
+ * emporté par le groupe du superviseur de développement. Sous Windows il n'y a pas de
552
+ * groupe du tout : les descendants survivaient à chaque arrêt, et `stopDev()` laissait
553
+ * un service esbuild derrière lui.
554
+ *
555
+ * Une seule implémentation dans le dépôt ({@link signalProcessGroup}, cœur) : groupe
556
+ * POSIX ou `taskkill /T` selon la plateforme. La recopier ici aurait fait réapparaître
557
+ * l'angle mort qu'on venait de fermer dans le superviseur de développement.
558
+ *
559
+ * @returns ce qui a pu être atteint. `gone` = plus rien à tuer, donc plus aucun `exit`
560
+ * à attendre : c'est un VERDICT, là où l'arrêt reposait jusqu'ici sur une exception
561
+ * remontée par `child.kill()`. Une exception dit qu'un appel a échoué, pas ce qu'il
562
+ * est advenu du process — et elle disparaît dès qu'un intermédiaire la rattrape.
563
+ */
564
+ signalTree(child, signal) {
565
+ if (typeof child.pid !== "number") try {
566
+ child.kill(signal);
567
+ return "single";
568
+ } catch {
569
+ return "gone";
570
+ }
571
+ return signalProcessGroup(child.pid, signal);
572
+ }
573
+ trackListener(target, event, fn) {
574
+ target.on(event, fn);
575
+ this.childListeners.push({
576
+ target,
577
+ event,
578
+ fn
579
+ });
580
+ }
581
+ cleanupChildListeners() {
582
+ for (const { target, event, fn } of this.childListeners) try {
583
+ target.removeListener(event, fn);
584
+ } catch {}
585
+ this.childListeners = [];
586
+ }
587
+ };
588
+ //#endregion
589
+ export { ViteProcessSupervisor, ViteProcessSupervisor as default, isPortInUseMessage, startupTimeoutMessage };
@@ -0,0 +1,113 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ //#region nodefony/src/FrontendAdminApi.ts
5
+ const requireFrom = createRequire(import.meta.url);
6
+ const versionCache = /* @__PURE__ */ new Map();
7
+ /**
8
+ * Lit la `version` d'un package installé (best-effort, jamais throw). Tente d'abord
9
+ * `<pkg>/package.json` (la plupart l'exportent), sinon remonte depuis le main résolu
10
+ * jusqu'au `package.json` du package. Résultat caché (lu une seule fois).
11
+ */
12
+ function pkgVersion(pkg) {
13
+ const cached = versionCache.get(pkg);
14
+ if (cached !== void 0) return cached ?? void 0;
15
+ let v = null;
16
+ try {
17
+ const p = requireFrom.resolve(`${pkg}/package.json`);
18
+ v = JSON.parse(readFileSync(p, "utf8")).version ?? null;
19
+ } catch {
20
+ try {
21
+ let dir = path.dirname(requireFrom.resolve(pkg));
22
+ for (let i = 0; i < 6 && dir; i++) {
23
+ const cand = path.join(dir, "package.json");
24
+ if (existsSync(cand)) {
25
+ const j = JSON.parse(readFileSync(cand, "utf8"));
26
+ if (j.name === pkg) {
27
+ v = j.version ?? null;
28
+ break;
29
+ }
30
+ }
31
+ const parent = path.dirname(dir);
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ } catch {
36
+ v = null;
37
+ }
38
+ }
39
+ versionCache.set(pkg, v);
40
+ return v ?? void 0;
41
+ }
42
+ /** Package npm du framework UI derrière un type de preset (pour la version). */
43
+ function frameworkPkg(type) {
44
+ switch (type) {
45
+ case "react19": return "react";
46
+ case "vue3": return "vue";
47
+ case "angular": return "@angular/core";
48
+ default: return null;
49
+ }
50
+ }
51
+ /** Mappe un statut superviseur vers la vue sûre (sans paths) + versions framework. */
52
+ function toView(family, status) {
53
+ return {
54
+ family,
55
+ state: status.state,
56
+ host: status.host,
57
+ origin: status.origin,
58
+ port: status.port,
59
+ pid: status.pid,
60
+ https: status.https,
61
+ restartCount: status.restartCount,
62
+ healthFailures: status.healthFailures,
63
+ entries: status.entries.map((e) => {
64
+ const pkg = frameworkPkg(e.type);
65
+ return {
66
+ entryName: e.entryName,
67
+ type: e.type,
68
+ version: pkg ? pkgVersion(pkg) : void 0
69
+ };
70
+ })
71
+ };
72
+ }
73
+ /**
74
+ * Construit le snapshot frontend (état Vite) — lecture pure, jamais throw.
75
+ *
76
+ * @param service - le {@link FrontendService} (résolu du container).
77
+ * @returns la vue sûre prête à `renderJson` / push realtime.
78
+ */
79
+ function buildFrontendStatus(service) {
80
+ const bundles = service.statusAll().map((b) => toView(b.family, b.status));
81
+ const primary = toView("default", service.status());
82
+ return {
83
+ available: bundles.some((b) => b.state === "ready"),
84
+ vite: pkgVersion("vite"),
85
+ primary,
86
+ bundles
87
+ };
88
+ }
89
+ const descriptor = {
90
+ label: "Frontend",
91
+ icon: "bolt",
92
+ order: 6
93
+ };
94
+ /**
95
+ * Construit le producteur `IAdminApi` du frontend (namespace `"frontend"`).
96
+ *
97
+ * @param service - le {@link FrontendService} à introspecter.
98
+ * @returns le contrat admin, prêt à `broker.register()`.
99
+ */
100
+ function createFrontendAdminApi(service) {
101
+ const endpoints = [{
102
+ path: "vite",
103
+ summary: "État du superviseur Vite (dev) : process (pid), port réel, état (HMR actif si ready), bundles servis. Vide en prod (Vite ne tourne pas — UI servie par le bundle compilé).",
104
+ handler: () => buildFrontendStatus(service)
105
+ }];
106
+ return {
107
+ adminNamespace: "frontend",
108
+ adminDescriptor: () => descriptor,
109
+ adminEndpoints: () => endpoints
110
+ };
111
+ }
112
+ //#endregion
113
+ export { buildFrontendStatus, createFrontendAdminApi };