@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.
- package/LICENSE +544 -0
- package/README.md +338 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +63 -0
- package/dist/nodefony/command/frontend-build.js +68 -0
- package/dist/nodefony/command/frontend-dev.js +31 -0
- package/dist/nodefony/command/frontend-status.js +40 -0
- package/dist/nodefony/config/config.js +65 -0
- package/dist/nodefony/config/defineModuleConfig.js +34 -0
- package/dist/nodefony/interfaces/IFrontBuilder.js +1 -0
- package/dist/nodefony/interfaces/IFrontPreset.js +1 -0
- package/dist/nodefony/interfaces/IFrontendService.js +1 -0
- package/dist/nodefony/interfaces/IViteSupervisor.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/FrontendService.js +708 -0
- package/dist/nodefony/service/ViteConfigGenerator.js +139 -0
- package/dist/nodefony/service/ViteProcessSupervisor.js +589 -0
- package/dist/nodefony/src/FrontendAdminApi.js +113 -0
- package/dist/nodefony/src/builders/ViteBuilder.js +75 -0
- package/dist/nodefony/src/errors/FrontendError.js +50 -0
- package/dist/nodefony/src/isolationGroups.js +116 -0
- package/dist/nodefony/src/presets/angular-vite.js +27 -0
- package/dist/nodefony/src/presets/react19-vite.js +26 -0
- package/dist/nodefony/src/presets/svelte5-vite.js +37 -0
- package/dist/nodefony/src/presets/vanilla-vite.js +17 -0
- package/dist/nodefony/src/presets/vue3-vite.js +23 -0
- package/dist/nodefony/src/remoteDev.js +157 -0
- package/dist/nodefony/src/template/TemplateHelper.js +255 -0
- package/dist/types/index.d.ts +51 -0
- package/dist/types/nodefony/command/frontend-build.d.ts +17 -0
- package/dist/types/nodefony/command/frontend-dev.d.ts +12 -0
- package/dist/types/nodefony/command/frontend-status.d.ts +14 -0
- package/dist/types/nodefony/config/config.d.ts +38 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -0
- package/dist/types/nodefony/interfaces/IFrontBuilder.d.ts +69 -0
- package/dist/types/nodefony/interfaces/IFrontPreset.d.ts +26 -0
- package/dist/types/nodefony/interfaces/IFrontendService.d.ts +77 -0
- package/dist/types/nodefony/interfaces/IViteSupervisor.d.ts +56 -0
- package/dist/types/nodefony/interfaces/index.d.ts +4 -0
- package/dist/types/nodefony/service/FrontendService.d.ts +230 -0
- package/dist/types/nodefony/service/ViteConfigGenerator.d.ts +66 -0
- package/dist/types/nodefony/service/ViteProcessSupervisor.d.ts +214 -0
- package/dist/types/nodefony/src/FrontendAdminApi.d.ts +63 -0
- package/dist/types/nodefony/src/builders/ViteBuilder.d.ts +17 -0
- package/dist/types/nodefony/src/errors/FrontendError.d.ts +34 -0
- package/dist/types/nodefony/src/isolationGroups.d.ts +89 -0
- package/dist/types/nodefony/src/presets/angular-vite.d.ts +15 -0
- package/dist/types/nodefony/src/presets/react19-vite.d.ts +9 -0
- package/dist/types/nodefony/src/presets/svelte5-vite.d.ts +13 -0
- package/dist/types/nodefony/src/presets/vanilla-vite.d.ts +9 -0
- package/dist/types/nodefony/src/presets/vue3-vite.d.ts +11 -0
- package/dist/types/nodefony/src/remoteDev.d.ts +107 -0
- package/dist/types/nodefony/src/template/TemplateHelper.d.ts +99 -0
- package/docs/index.md +925 -0
- 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 };
|