@nodefony/frontend 10.0.0-alpha.3 → 10.0.0-alpha.5

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.
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -10,8 +10,8 @@ import { ViteBuilder } from "./nodefony/src/builders/ViteBuilder.js";
10
10
  import { ViteConfigGenerator } from "./nodefony/service/ViteConfigGenerator.js";
11
11
  import { ViteProcessSupervisor } from "./nodefony/service/ViteProcessSupervisor.js";
12
12
  import { TemplateHelper } from "./nodefony/src/template/TemplateHelper.js";
13
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
14
- import __decorate from "./_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
13
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
14
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
15
15
  import FrontendService_default from "./nodefony/service/FrontendService.js";
16
16
  import { buildFrontendStatus, createFrontendAdminApi } from "./nodefony/src/FrontendAdminApi.js";
17
17
  import FrontendBuild from "./nodefony/command/frontend-build.js";
@@ -42,7 +42,7 @@ const resilienceSchema = z.strictObject({
42
42
  const frontendConfigSchema = z.strictObject({
43
43
  devHost: z.string().default("127.0.0.1").describe("Host d'écoute du dev server Vite — utilisé tel quel dans les `<script>` injectés (doit être joignable depuis le navigateur). Prod : N/A (Vite ne tourne pas en prod, le manifest pilote)."),
44
44
  devPort: z.number().int().positive().default(5173).describe("Port d'écoute du dev server Vite (5173 par défaut) — port de BASE : chaque famille de frontends prend le bloc suivant. Si occupé, c'est le SUPERVISEUR qui relance sur le port suivant (`resilience.portRetryAttempts` essais) et publie le port réel dans son `status()` — Vite, lui, ne se décale jamais seul : le fichier généré porte `strictPort` pour que l'origine annoncée au navigateur soit toujours celle qui sert."),
45
- publicOrigin: z.string().default("").refine((v) => v === "" || /^https?:\/\/[^/\s]+$/.test(v), { message: "publicOrigin doit être une origine (`scheme://host[:port]`), sans chemin" }).describe("Origine PUBLIQUE du dev server Vite — celle que le NAVIGATEUR utilise, quand elle diffère de l'adresse d'écoute (`devHost`). ÉPINGLE le rendu sur une origine unique : à réserver aux cas où un frontal la réécrit (tunnel, proxy, port remappé). Utilisée telle quelle (port inclus SEULEMENT si écrit) dans les `<script>` injectés, le `base` Vite et le WebSocket HMR (`hmr.host`/`clientPort`, dérivés). Vide (défaut, RECOMMANDÉ) = chaque page annonce l'origine par laquelle le client est arrivé (`Host` de la requête, scheme et port de Vite) : un poste et un navigateur en conteneur sont servis EN MÊME TEMPS par la même instance, sans configuration — et Codespaces/Gitpod restent détectés automatiquement. L'hôte d'une origine épinglée est automatiquement autorisé par Vite (`server.allowedHosts`) ; les hôtes suivis par la dérivation sont ceux de `trustedHosts` de @nodefony/http (une seule liste à maintenir : elle ouvre la barrière 421, Vite, le CSP et le rendu)."),
45
+ publicOrigin: z.string().default("").refine((v) => v === "" || /^https?:\/\/[^/\s]+$/.test(v), { message: "publicOrigin doit être une origine (`scheme://host[:port]`), sans chemin" }).describe("Origine PUBLIQUE du dev server Vite — celle que le NAVIGATEUR utilise, quand elle diffère de l'adresse d'écoute (`devHost`). ÉPINGLE le rendu sur une origine unique : à réserver aux cas où un frontal la réécrit (tunnel, proxy, port remappé). Utilisée telle quelle (port inclus SEULEMENT si écrit) dans les `<script>` injectés, le `base` Vite et le WebSocket HMR (`hmr.host`/`clientPort`, dérivés). Vide (défaut, RECOMMANDÉ) = chaque page annonce l'origine par laquelle le client est arrivé (`Host` de la requête, scheme et port de Vite) : un poste et un navigateur en conteneur sont servis EN MÊME TEMPS par la même instance, sans configuration. Codespaces/Gitpod sont détectés automatiquement et fournissent alors l'origine par défaut — MAIS un client arrivé par la boucle locale (tunnel de port : VS Code Desktop le fait par défaut) reste servi en local, parce que l'origine publique d'une plateforme exige sa session, qu'une intégration continue ou une sonde n'a pas. Une origine écrite ICI, en revanche, gagne sur tout — c'est un réglage, pas une déduction. L'hôte d'une origine épinglée est automatiquement autorisé par Vite (`server.allowedHosts`) ; les hôtes suivis par la dérivation sont ceux de `trustedHosts` de @nodefony/http (une seule liste à maintenir : elle ouvre la barrière 421, Vite, le CSP et le rendu)."),
46
46
  autoStartInDevelopment: z.boolean().default(true).describe("Démarre automatiquement le superviseur Vite quand le kernel passe en `development`. Ignoré en `production`/`staging`. Reco : true en dev, sinon les helpers template injecteront une URL morte."),
47
47
  defaultOutDir: z.string().default("./public/dist").describe("Dossier de sortie par défaut pour le build prod, relatif à la racine du module consommateur. Réécrit par la prop `outDir` de la déclaration d'entrée."),
48
48
  defaultRoot: z.string().default("./frontend").describe("Racine front par défaut (contient `index.html`) côté module."),
@@ -1,12 +1,12 @@
1
1
  import config from "../config/config.js";
2
2
  import { FrontendNoEntriesError, FrontendSupervisorStartError } from "../src/errors/FrontendError.js";
3
3
  import { ViteBuilder } from "../src/builders/ViteBuilder.js";
4
- import { allowedHostPatternForTemplate, detectRemoteDev, isValidOriginTemplate, viteAllowedHostFromPattern } from "../src/remoteDev.js";
4
+ import { allowedHostPatternForTemplate, detectRemoteDev, isLoopbackHostname, isValidOriginTemplate, viteAllowedHostFromPattern } from "../src/remoteDev.js";
5
5
  import { ViteProcessSupervisor } from "./ViteProcessSupervisor.js";
6
6
  import { TemplateHelper } from "../src/template/TemplateHelper.js";
7
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";
8
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
9
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
10
10
  import { Module, Service, extend, injectable, stripTrailingSlashes } from "nodefony";
11
11
  import path from "node:path";
12
12
  import fs from "node:fs";
@@ -47,12 +47,23 @@ let FrontendService = class FrontendService extends Service {
47
47
  /** Helper prod unique (lit les manifests) — `null` tant qu'on n'est pas en prod. */
48
48
  prodHelper = null;
49
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).
50
+ * QUI a épinglé l'origine publique la question n'est pas « est-elle
51
+ * épinglée » mais « par quoi », car les deux sources n'ont pas la même
52
+ * autorité :
53
+ *
54
+ * - `"config"` — `frontend.publicOrigin` : une décision ÉCRITE par l'auteur.
55
+ * Elle gagne sur tout, y compris sur le `Host` reçu : c'est le sens même
56
+ * d'un réglage explicite, et le seul moyen de servir derrière un frontal
57
+ * qui réécrit l'origine.
58
+ * - `"platform"` — Codespaces/Gitpod déduits de l'environnement : une
59
+ * DÉDUCTION, faite une fois au démarrage, sur une machine qui reçoit
60
+ * simultanément des clients arrivés par des chemins différents. Elle ne
61
+ * peut donc pas prévaloir sur un fait constaté à la requête — un client
62
+ * venu de la boucle locale se sert en local (cf `derivableHost`).
63
+ * - `null` — rien d'épinglé : chaque page annonce l'origine par laquelle son
64
+ * client est arrivé.
54
65
  */
55
- originPinned = false;
66
+ originPinnedBy = null;
56
67
  /**
57
68
  * Ports que l'instance Vite de chaque famille PEUT prendre pour ce démarrage
58
69
  * (bloc de la famille, port-retry compris) — `null` tant que `startDev` n'a
@@ -195,8 +206,9 @@ let FrontendService = class FrontendService extends Service {
195
206
  if (this.supervisors.size > 0 && [...this.supervisors.values()].some((s) => s.status().state === "ready")) return;
196
207
  const backendOrigin = `${this.cfg.backendProtocol}://${this.cfg.backendHost}:${this.resolveBackendPort()}`;
197
208
  const https = this.resolveHttps();
198
- const publicOriginTemplate = this.resolvePublicOriginTemplate();
199
- this.originPinned = publicOriginTemplate !== void 0;
209
+ const pinned = this.resolvePublicOrigin();
210
+ const publicOriginTemplate = pinned?.template;
211
+ this.originPinnedBy = pinned?.source ?? null;
200
212
  const allowedHosts = this.viteAllowedHosts(publicOriginTemplate);
201
213
  const nodeEnv = this.kernel?.environment;
202
214
  const extraEnv = this.cfg.viteEnv ?? {};
@@ -279,28 +291,43 @@ let FrontendService = class FrontendService extends Service {
279
291
  };
280
292
  }
281
293
  /**
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.
294
+ * Template d'origine publique Vite (P14.17), AVEC sa provenance. Priorité :
295
+ * `frontend.publicOrigin` (config, validée — invalide = ERROR + ignorée,
296
+ * jamais un boot cassé) puis détection de plateforme (Codespaces/Gitpod —
297
+ * variables documentées de la plateforme, qu'on lit sans les posséder).
298
+ * `null` = dérivation locale.
299
+ *
300
+ * La provenance est rendue avec le template parce qu'elle CHANGE la suite :
301
+ * une config écrite est un ordre, une plateforme déduite est une supposition
302
+ * qui cède devant le `Host` reçu quand celui-ci désigne la boucle locale
303
+ * (cf `originPinnedBy`). Les confondre servait l'origine publique à un client
304
+ * venu d'un tunnel local, qui n'a pas la session de la plateforme.
305
+ *
286
306
  * Chaque adaptation est JOURNALISÉE : on doit pouvoir lire dans le boot
287
307
  * pourquoi les `<script>` pointent où ils pointent.
288
308
  */
289
- resolvePublicOriginTemplate() {
309
+ resolvePublicOrigin() {
290
310
  const cfgOrigin = this.cfg.publicOrigin;
291
311
  if (cfgOrigin) {
292
312
  if (!isValidOriginTemplate(cfgOrigin)) {
293
313
  this.log(`frontend.publicOrigin invalide (« ${cfgOrigin} ») — attendu scheme://host[:port|:{port}] sans chemin ; origine locale utilisée`, "ERROR");
294
- return;
314
+ return null;
295
315
  }
296
316
  this.log(`origine publique Vite (config) : ${cfgOrigin}`, "INFO");
297
- return cfgOrigin;
317
+ return {
318
+ template: cfgOrigin,
319
+ source: "config"
320
+ };
298
321
  }
299
322
  const detected = detectRemoteDev(process.env);
300
323
  if (detected) {
301
- this.log(`dev déporté détecté (${detected.provider}) — origine publique Vite : ` + detected.originTemplate, "INFO");
302
- return detected.originTemplate;
324
+ this.log(`dev déporté détecté (${detected.provider}) — origine publique Vite : ${detected.originTemplate} (un client arrivé par la boucle locale reste servi en local)`, "INFO");
325
+ return {
326
+ template: detected.originTemplate,
327
+ source: "platform"
328
+ };
303
329
  }
330
+ return null;
304
331
  }
305
332
  /**
306
333
  * `server.allowedHosts` pour Vite. Vite accepte d'office IP et `localhost` ;
@@ -581,7 +608,9 @@ let FrontendService = class FrontendService extends Service {
581
608
  * résolue au démarrage (comportement d'avant la dérivation).
582
609
  */
583
610
  derivableHost(requestHost) {
584
- if (!requestHost || this.originPinned) return void 0;
611
+ if (!requestHost) return void 0;
612
+ if (this.originPinnedBy === "config") return void 0;
613
+ if (this.originPinnedBy === "platform" && !isLoopbackHostname(requestHost)) return;
585
614
  const httpKernel = this.container?.get?.("HttpKernel");
586
615
  if (!httpKernel || httpKernel.trustedHosts === true) return void 0;
587
616
  return httpKernel.isTrustedHostname?.(requestHost) === true ? requestHost : void 0;
@@ -89,17 +89,16 @@ ${fsAllowLines}
89
89
  },
90
90
  `;
91
91
  const allowedHostsLine = opts.allowedHosts === true ? ` allowedHosts: true,\n` : opts.allowedHosts && opts.allowedHosts.length > 0 ? ` allowedHosts: ${JSON.stringify(opts.allowedHosts)},\n` : "";
92
- const hmrLine = opts.hmr ? ` hmr: { host: ${JSON.stringify(opts.hmr.host)}, clientPort: ${opts.hmr.clientPort}, protocol: ${JSON.stringify(opts.hmr.protocol)} },\n` : "";
93
92
  const serverBlock = proxyPaths.size > 0 ? ` server: {
94
93
  strictPort: ${strictPort},
95
94
  cors: true,
96
- ${allowedHostsLine}${hmrLine}${httpsLines}${fsBlock} proxy: {
95
+ ${allowedHostsLine}${httpsLines}${fsBlock} proxy: {
97
96
  ${proxyLines}
98
97
  },
99
98
  },` : ` server: {
100
99
  strictPort: ${strictPort},
101
100
  cors: true,
102
- ${allowedHostsLine}${hmrLine}${httpsLines}${fsBlock} },`;
101
+ ${allowedHostsLine}${httpsLines}${fsBlock} },`;
103
102
  const baseLine = useViteOrigin ? ` base: ${JSON.stringify(opts.viteOrigin + "/")},\n` : "";
104
103
  const dedupe = [];
105
104
  if (usedTypes.has("react19")) dedupe.push("react", "react-dom");
@@ -240,8 +240,7 @@ var ViteProcessSupervisor = class {
240
240
  backendOrigin: this.opts.backendOrigin,
241
241
  viteOrigin,
242
242
  https: this.opts.https,
243
- allowedHosts: this.opts.allowedHosts,
244
- hmr: resolved?.hmr
243
+ allowedHosts: this.opts.allowedHosts
245
244
  });
246
245
  writeFileSync(this.configFilePath, content, "utf8");
247
246
  this.opts.logger.debug?.(`vite config written: ${this.configFilePath}`);
@@ -7,6 +7,50 @@ import svelte5Preset from "../presets/svelte5-vite.js";
7
7
  import path from "node:path";
8
8
  //#region nodefony/src/builders/ViteBuilder.ts
9
9
  /**
10
+ * Les paquets à dédupliquer pour un lot de préréglages — la règle, isolée.
11
+ *
12
+ * Pure et exportée pour DEUX raisons. Elle ne dépend que des préréglages
13
+ * utilisés : la faire passer par `buildViteConfig` obligeait son test à charger
14
+ * réellement les plugins Vite depuis le disque, et sous la contention d'une
15
+ * passe complète ce chargement a dépassé le budget de temps par défaut — un
16
+ * rouge qui ne disait rien du produit. Et c'est la règle même qui avait divergé
17
+ * de son jumeau du développement : elle mérite d'être nommée.
18
+ *
19
+ * MÊME règle que le fichier dev généré (ViteConfigGenerator) : dans une app
20
+ * liée (`--link`), un import `react` émis par la façade `nodefony/react`
21
+ * (réelpathée HORS de l'app, dans le checkout) se résout dans le
22
+ * node_modules du framework pendant qu'`App.tsx` résout celui de l'app →
23
+ * DEUX runtimes React dans le bundle prod, hooks au dispatcher null
24
+ * (« Cannot read properties of null (reading 'useContext') » au mount —
25
+ * vécu ; le dev ne le voyait pas : le prébundle unifie). `resolve.dedupe`
26
+ * force une seule résolution, celle du root Vite.
27
+ *
28
+ * Ce que chaque préréglage doit VRAIMENT à cette liste, mesuré dans les
29
+ * plugins installés — parce que la moitié d'entre eux le font déjà :
30
+ * • react : `@vitejs/plugin-react` ne pose AUCUN dedupe → notre ligne
31
+ * est la seule protection, et c'est bien celle dont l'absence a été
32
+ * vécue en production ;
33
+ * • angular : `@analogjs/vite-plugin-angular` non plus ;
34
+ * • vue : `@vitejs/plugin-vue` pose `resolve: { dedupe: ["vue"] }` ;
35
+ * • svelte : `@sveltejs/vite-plugin-svelte` pose `SVELTE_DEDUPED_IMPORTS`,
36
+ * qui couvre `svelte` ET ses sous-chemins.
37
+ * Les deux derniers sont donc une DÉFENSE EN PROFONDEUR, pas un correctif :
38
+ * ils gardent cette liste symétrique de celle du développement, et nous
39
+ * rendent indépendants d'un détail d'implémentation de plugin tiers qui
40
+ * peut disparaître à une majeure sans que personne ne le remarque.
41
+ *
42
+ * @param usedPresets - les préréglages effectivement présents dans les entrées
43
+ * @returns les spécificateurs à passer à `resolve.dedupe`, dans l'ordre
44
+ */
45
+ function resolveDedupe(usedPresets) {
46
+ const dedupe = [];
47
+ if (usedPresets.has("react19")) dedupe.push("react", "react-dom");
48
+ if (usedPresets.has("vue3")) dedupe.push("vue");
49
+ if (usedPresets.has("svelte5")) dedupe.push("svelte");
50
+ if (usedPresets.has("angular")) dedupe.push("@angular/core", "@angular/common", "@angular/platform-browser");
51
+ return dedupe;
52
+ }
53
+ /**
10
54
  * Construit la config Vite finale à partir des entrées résolues et des presets.
11
55
  *
12
56
  * Le builder ne lance JAMAIS Vite — il fournit uniquement la config. Le
@@ -47,10 +91,7 @@ var ViteBuilder = class {
47
91
  const root = entries[0].root;
48
92
  const outDir = entries[0].outDir;
49
93
  const base = mode === "production" ? assetBaseUrl + entries[0].publicPath : void 0;
50
- const dedupe = [];
51
- if (usedPresets.has("react19")) dedupe.push("react", "react-dom");
52
- if (usedPresets.has("vue3")) dedupe.push("vue");
53
- if (usedPresets.has("angular")) dedupe.push("@angular/core", "@angular/common", "@angular/platform-browser");
94
+ const dedupe = resolveDedupe(usedPresets);
54
95
  return {
55
96
  mode,
56
97
  ...base ? { base } : {},
@@ -72,4 +113,4 @@ var ViteBuilder = class {
72
113
  }
73
114
  };
74
115
  //#endregion
75
- export { ViteBuilder, ViteBuilder as default };
116
+ export { ViteBuilder, ViteBuilder as default, resolveDedupe };
@@ -6,7 +6,7 @@
6
6
  * NAVIGATEUR utilise peut être toute autre chose — un forwarder TLS (Codespaces,
7
7
  * Gitpod), une passerelle de conteneur (`host.docker.internal`), un port remappé.
8
8
  * Ce module dissocie les deux : il produit l'origine publique (assets, `base`
9
- * Vite, WebSocket HMR) à partir d'un TEMPLATE (`{port}` substitué au port réel
9
+ * Vite) à partir d'un TEMPLATE (`{port}` substitué au port réel
10
10
  * du spawn) — explicite (`frontend.publicOrigin`) ou détecté depuis
11
11
  * l'environnement de la plateforme.
12
12
  *
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Formats VÉRIFIÉS (docs officielles + source Vite 8) :
17
17
  * - Codespaces : `https://${CODESPACE_NAME}-${port}.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`
18
- * (TLS terminé par le forwarder WS HMR en `wss` sur 443).
18
+ * (TLS terminé par le forwarder ; le socket HMR s'en déduit côté client).
19
19
  * - Gitpod classic : `https://${port}-<hôte de GITPOD_WORKSPACE_URL>`.
20
20
  * - Vite `server.allowedHosts` : IP et `localhost`/`*.localhost` TOUJOURS
21
21
  * acceptés ; un préfixe `.` = le domaine ET tous ses sous-domaines.
@@ -25,6 +25,12 @@
25
25
  /** Placeholder substitué par le port réel du spawn dans un template d'origine. */
26
26
  const PORT_PLACEHOLDER = "{port}";
27
27
  /**
28
+ * `127.0.0.0/8` avec des octets BORNÉS (0-255). Pas de quantificateur imbriqué
29
+ * ni d'alternance qui se chevauche : linéaire sur toute entrée, y compris
30
+ * forgée (le `Host` est une donnée cliente).
31
+ */
32
+ const LOOPBACK_V4_RE = /^127\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
33
+ /**
28
34
  * Template d'origine publique : `scheme://hostTemplate[:portTemplate]`, sans
29
35
  * chemin. `{port}` peut apparaître dans l'hôte (Codespaces/Gitpod : port encodé
30
36
  * dans le sous-domaine) OU en position de port (`host:{port}`).
@@ -41,6 +47,34 @@ const ORIGIN_TEMPLATE_RE = /^(https?):\/\/([^/:\s]+)(?::(\d+|\{port\}))?$/;
41
47
  function browserReachableHost(listenHost) {
42
48
  return listenHost === "0.0.0.0" || listenHost === "::" || listenHost === "[::]" || listenHost === "" ? "127.0.0.1" : listenHost;
43
49
  }
50
+ /**
51
+ * Le client est-il arrivé par la BOUCLE LOCALE de la machine qui sert ?
52
+ *
53
+ * Ce n'est pas une commodité : c'est un fait vérifiable qui prime sur toute
54
+ * déduction faite au démarrage. Une plateforme de dev déporté se détecte par
55
+ * une variable d'environnement — donc UNE FOIS, au lancement du serveur — alors
56
+ * qu'un même serveur reçoit simultanément des clients arrivés par des chemins
57
+ * différents : l'origine publique de la plateforme, et un tunnel local (VS Code
58
+ * Desktop redirige les ports d'un Codespace sur `localhost`, c'est sa
59
+ * configuration par défaut).
60
+ *
61
+ * Servir l'origine publique à un client venu du tunnel a un coût réel : cette
62
+ * origine exige la session de la plateforme. Un navigateur humain la porte et
63
+ * ne voit rien ; une intégration continue, une sonde ou un agent ne l'ont pas,
64
+ * se font refuser, et obtiennent une page blanche que rien n'explique.
65
+ *
66
+ * La liste est FERMÉE, et c'est ce qui la rend sûre : le `Host` est une donnée
67
+ * cliente, et seuls ces noms désignent la machine locale de façon non
68
+ * ambiguë. `0.0.0.0`/`::` en sont exclus — ce sont des adresses d'écoute, pas
69
+ * des destinations (cf `browserReachableHost`).
70
+ *
71
+ * @param hostname - nom d'hôte NU, sans port (`[::1]` pour l'IPv6 canonique).
72
+ * @returns `true` si ce nom désigne la boucle locale.
73
+ */
74
+ function isLoopbackHostname(hostname) {
75
+ if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") return true;
76
+ return LOOPBACK_V4_RE.test(hostname);
77
+ }
44
78
  /** Un template d'origine est-il syntaxiquement valide ? (autorité unique) */
45
79
  function isValidOriginTemplate(template) {
46
80
  return ORIGIN_TEMPLATE_RE.test(template);
@@ -79,9 +113,10 @@ function originWithHostname(origin, hostname) {
79
113
  /**
80
114
  * Résout un template d'origine contre le port RÉEL du spawn. Pure.
81
115
  *
82
- * @returns origine + config HMR cliente, ou `null` si le template est invalide
116
+ * @returns l'origine publique, ou `null` si le template est invalide
83
117
  * (l'appelant retombe sur la dérivation locale en l'ANNONÇANT — jamais en
84
- * silence).
118
+ * silence). Aucune config HMR n'est rendue : le socket suit l'origine par
119
+ * laquelle le client Vite a été chargé, il n'a rien à recevoir.
85
120
  */
86
121
  function resolveOriginTemplate(template, port) {
87
122
  const m = ORIGIN_TEMPLATE_RE.exec(template);
@@ -89,15 +124,7 @@ function resolveOriginTemplate(template, port) {
89
124
  const [, scheme, hostTemplate, portTemplate] = m;
90
125
  const host = hostTemplate.replaceAll(PORT_PLACEHOLDER, String(port));
91
126
  const explicitPort = portTemplate ? parseInt(portTemplate.replaceAll(PORT_PLACEHOLDER, String(port)), 10) : void 0;
92
- const secure = scheme === "https";
93
- return {
94
- origin: `${scheme}://${host}${explicitPort !== void 0 ? `:${explicitPort}` : ""}`,
95
- hmr: {
96
- host,
97
- clientPort: explicitPort ?? (secure ? 443 : 80),
98
- protocol: secure ? "wss" : "ws"
99
- }
100
- };
127
+ return { origin: `${scheme}://${host}${explicitPort !== void 0 ? `:${explicitPort}` : ""}` };
101
128
  }
102
129
  /**
103
130
  * Motif `server.allowedHosts` couvrant TOUTES les origines qu'un template peut
@@ -154,4 +181,4 @@ function detectRemoteDev(env) {
154
181
  return null;
155
182
  }
156
183
  //#endregion
157
- export { PORT_PLACEHOLDER, allowedHostPatternForTemplate, browserReachableHost, detectRemoteDev, isValidOriginTemplate, originWithHostname, resolveOriginTemplate, viteAllowedHostFromPattern };
184
+ export { PORT_PLACEHOLDER, allowedHostPatternForTemplate, browserReachableHost, detectRemoteDev, isLoopbackHostname, isValidOriginTemplate, originWithHostname, resolveOriginTemplate, viteAllowedHostFromPattern };
@@ -1,4 +1,4 @@
1
- import { originWithHostname } from "../remoteDev.js";
1
+ import { isLoopbackHostname, originWithHostname } from "../remoteDev.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { PLATFORM_EVENTS, escapeRegExp } from "nodefony";
4
4
  import path from "node:path";
@@ -120,7 +120,7 @@ ${tags}
120
120
  const entry = status.entries.find((e) => e.entryName === entryName);
121
121
  if (!entry) return `<!-- @nodefony/frontend: unknown entry "${entryName}" -->`;
122
122
  const resolvedOrigin = status.origin ?? `${status.https ? "https" : "http"}://${status.host}:${status.port}`;
123
- const baseUrl = requestHost ? originWithHostname(resolvedOrigin, requestHost) ?? resolvedOrigin : resolvedOrigin;
123
+ const baseUrl = !requestHost ? resolvedOrigin : isLoopbackHostname(requestHost) ? `${status.https ? "https" : "http"}://${requestHost}:${status.port}` : originWithHostname(resolvedOrigin, requestHost) ?? resolvedOrigin;
124
124
  const absEntryPath = path.resolve(entry.root, entry.entryFile).replace(/\\/g, "/");
125
125
  const entryUrl = `${baseUrl}${absEntryPath.startsWith("/") ? `/@fs${absEntryPath}` : `/@fs/${absEntryPath}`}`;
126
126
  const tags = [];
@@ -1,13 +1,13 @@
1
1
  import { Kernel, Module } from "nodefony";
2
2
  import { type IFrontendConfigInput } from "./nodefony/config/defineModuleConfig.js";
3
- import type { FrontendConfig } from "./nodefony/config/config.js";
3
+ import type { IFrontendConfig } from "./nodefony/config/config.js";
4
4
  import FrontendService from "./nodefony/service/FrontendService.js";
5
5
  declare module "nodefony" {
6
6
  interface NodefonyModuleConfig {
7
7
  "@nodefony/frontend": IFrontendConfigInput;
8
8
  }
9
9
  }
10
- declare class Frontend extends Module<FrontendConfig> {
10
+ declare class Frontend extends Module<IFrontendConfig> {
11
11
  constructor(kernel: Kernel);
12
12
  /** JSON Schema de la config frontend → data plane admin (config riche Studio). */
13
13
  configSchema(): unknown;
@@ -48,4 +48,4 @@ export type { IFrontBuilder, IFrontendModuleDeclaration, IResolvedFrontendEntry,
48
48
  export type { IViteSupervisor, IViteSupervisorStatus, ViteSupervisorState, } from "./nodefony/interfaces/IViteSupervisor.js";
49
49
  export type { IFrontendService } from "./nodefony/interfaces/IFrontendService.js";
50
50
  export { defineFrontendConfig, frontendConfigJsonSchema, type IFrontendConfigInput, } from "./nodefony/config/defineModuleConfig.js";
51
- export { frontendConfigSchema, type FrontendConfig, } from "./nodefony/config/config.js";
51
+ export { frontendConfigSchema, type IFrontendConfig, } from "./nodefony/config/config.js";
@@ -29,10 +29,10 @@ export declare const frontendConfigSchema: z.ZodObject<{
29
29
  }, z.core.$strict>>;
30
30
  }, z.core.$strict>;
31
31
  /** Type de sortie (config normalisée + défauts appliqués). */
32
- export type FrontendConfig = z.infer<typeof frontendConfigSchema>;
32
+ export type IFrontendConfig = z.infer<typeof frontendConfigSchema>;
33
33
  /**
34
34
  * Défauts du module, matérialisés depuis le schéma (source unique). Toujours
35
35
  * valides par construction ; passés au `super(..., config)` du Module class.
36
36
  */
37
- declare const config: FrontendConfig;
37
+ declare const config: IFrontendConfig;
38
38
  export default config;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { frontendConfigSchema, type FrontendConfig } from "./config.js";
2
+ import { frontendConfigSchema, type IFrontendConfig } from "./config.js";
3
3
  /**
4
4
  * Builder type-safe de la configuration de `@nodefony/frontend`.
5
5
  *
@@ -18,7 +18,7 @@ import { frontendConfigSchema, type FrontendConfig } from "./config.js";
18
18
  * @returns config gelée prête pour `FrontendService`.
19
19
  * @throws ZodError si invalide.
20
20
  */
21
- export declare function defineFrontendConfig(config?: IFrontendConfigInput): FrontendConfig;
21
+ export declare function defineFrontendConfig(config?: IFrontendConfigInput): IFrontendConfig;
22
22
  /**
23
23
  * JSON Schema introspectable de la config frontend — destiné au panneau de config
24
24
  * Studio (`/nodefony/config`).
@@ -26,4 +26,3 @@ export declare function defineFrontendConfig(config?: IFrontendConfigInput): Fro
26
26
  export declare function frontendConfigJsonSchema(): unknown;
27
27
  /** Entrée du builder (champs avec défaut optionnels). */
28
28
  export type IFrontendConfigInput = z.input<typeof frontendConfigSchema>;
29
- export type { FrontendConfig };
@@ -29,12 +29,23 @@ declare class FrontendService extends Service implements IFrontendService {
29
29
  /** Helper prod unique (lit les manifests) — `null` tant qu'on n'est pas en prod. */
30
30
  private prodHelper;
31
31
  /**
32
- * L'origine publique est-elle ÉPINGLÉE par une décision explicite
33
- * (`frontend.publicOrigin` en config, ou plateforme de dev déporté détectée) ?
34
- * `true` → la dérivation par `Host` est désactivée : un réglage voulu gagne
35
- * toujours sur une déduction (cf ordre de priorité, README du module).
36
- */
37
- private originPinned;
32
+ * QUI a épinglé l'origine publique la question n'est pas « est-elle
33
+ * épinglée » mais « par quoi », car les deux sources n'ont pas la même
34
+ * autorité :
35
+ *
36
+ * - `"config"` — `frontend.publicOrigin` : une décision ÉCRITE par l'auteur.
37
+ * Elle gagne sur tout, y compris sur le `Host` reçu : c'est le sens même
38
+ * d'un réglage explicite, et le seul moyen de servir derrière un frontal
39
+ * qui réécrit l'origine.
40
+ * - `"platform"` — Codespaces/Gitpod déduits de l'environnement : une
41
+ * DÉDUCTION, faite une fois au démarrage, sur une machine qui reçoit
42
+ * simultanément des clients arrivés par des chemins différents. Elle ne
43
+ * peut donc pas prévaloir sur un fait constaté à la requête — un client
44
+ * venu de la boucle locale se sert en local (cf `derivableHost`).
45
+ * - `null` — rien d'épinglé : chaque page annonce l'origine par laquelle son
46
+ * client est arrivé.
47
+ */
48
+ private originPinnedBy;
38
49
  /**
39
50
  * Ports que l'instance Vite de chaque famille PEUT prendre pour ce démarrage
40
51
  * (bloc de la famille, port-retry compris) — `null` tant que `startDev` n'a
@@ -100,14 +111,22 @@ declare class FrontendService extends Service implements IFrontendService {
100
111
  */
101
112
  private resolveHttps;
102
113
  /**
103
- * Template d'origine publique Vite (P14.17). Priorité : `frontend.publicOrigin`
104
- * (config, validée — invalide = ERROR + ignorée, jamais un boot cassé) puis
105
- * détection de plateforme (Codespaces/Gitpod — variables documentées de la
106
- * plateforme, qu'on lit sans les posséder). `undefined` = dérivation locale.
114
+ * Template d'origine publique Vite (P14.17), AVEC sa provenance. Priorité :
115
+ * `frontend.publicOrigin` (config, validée — invalide = ERROR + ignorée,
116
+ * jamais un boot cassé) puis détection de plateforme (Codespaces/Gitpod —
117
+ * variables documentées de la plateforme, qu'on lit sans les posséder).
118
+ * `null` = dérivation locale.
119
+ *
120
+ * La provenance est rendue avec le template parce qu'elle CHANGE la suite :
121
+ * une config écrite est un ordre, une plateforme déduite est une supposition
122
+ * qui cède devant le `Host` reçu quand celui-ci désigne la boucle locale
123
+ * (cf `originPinnedBy`). Les confondre servait l'origine publique à un client
124
+ * venu d'un tunnel local, qui n'a pas la session de la plateforme.
125
+ *
107
126
  * Chaque adaptation est JOURNALISÉE : on doit pouvoir lire dans le boot
108
127
  * pourquoi les `<script>` pointent où ils pointent.
109
128
  */
110
- private resolvePublicOriginTemplate;
129
+ private resolvePublicOrigin;
111
130
  /**
112
131
  * `server.allowedHosts` pour Vite. Vite accepte d'office IP et `localhost` ;
113
132
  * cette liste ne porte que les NOMS. Source des noms légitimes = la MÊME que
@@ -39,17 +39,6 @@ export interface ViteConfigGeneratorOptions {
39
39
  * sert que les NOMS (vhosts, `host.docker.internal`, forwarders).
40
40
  */
41
41
  readonly allowedHosts?: true | ReadonlyArray<string>;
42
- /**
43
- * Config `server.hmr` CLIENTE — où le navigateur ouvre le WebSocket HMR
44
- * quand un intermédiaire (forwarder TLS, passerelle conteneur) sépare
45
- * l'origine publique de l'adresse d'écoute. Absent = fallback client Vite
46
- * (`location.hostname` + port d'écoute), correct en local.
47
- */
48
- readonly hmr?: {
49
- readonly host: string;
50
- readonly clientPort: number;
51
- readonly protocol: "ws" | "wss";
52
- };
53
42
  }
54
43
  export declare class ViteConfigGenerator {
55
44
  /**
@@ -1,5 +1,42 @@
1
1
  import type { IFrontBuilder, IResolvedFrontendEntry } from "../../interfaces/IFrontBuilder.js";
2
2
  import type { IFrontPreset } from "../../interfaces/IFrontPreset.js";
3
+ /**
4
+ * Les paquets à dédupliquer pour un lot de préréglages — la règle, isolée.
5
+ *
6
+ * Pure et exportée pour DEUX raisons. Elle ne dépend que des préréglages
7
+ * utilisés : la faire passer par `buildViteConfig` obligeait son test à charger
8
+ * réellement les plugins Vite depuis le disque, et sous la contention d'une
9
+ * passe complète ce chargement a dépassé le budget de temps par défaut — un
10
+ * rouge qui ne disait rien du produit. Et c'est la règle même qui avait divergé
11
+ * de son jumeau du développement : elle mérite d'être nommée.
12
+ *
13
+ * MÊME règle que le fichier dev généré (ViteConfigGenerator) : dans une app
14
+ * liée (`--link`), un import `react` émis par la façade `nodefony/react`
15
+ * (réelpathée HORS de l'app, dans le checkout) se résout dans le
16
+ * node_modules du framework pendant qu'`App.tsx` résout celui de l'app →
17
+ * DEUX runtimes React dans le bundle prod, hooks au dispatcher null
18
+ * (« Cannot read properties of null (reading 'useContext') » au mount —
19
+ * vécu ; le dev ne le voyait pas : le prébundle unifie). `resolve.dedupe`
20
+ * force une seule résolution, celle du root Vite.
21
+ *
22
+ * Ce que chaque préréglage doit VRAIMENT à cette liste, mesuré dans les
23
+ * plugins installés — parce que la moitié d'entre eux le font déjà :
24
+ * • react : `@vitejs/plugin-react` ne pose AUCUN dedupe → notre ligne
25
+ * est la seule protection, et c'est bien celle dont l'absence a été
26
+ * vécue en production ;
27
+ * • angular : `@analogjs/vite-plugin-angular` non plus ;
28
+ * • vue : `@vitejs/plugin-vue` pose `resolve: { dedupe: ["vue"] }` ;
29
+ * • svelte : `@sveltejs/vite-plugin-svelte` pose `SVELTE_DEDUPED_IMPORTS`,
30
+ * qui couvre `svelte` ET ses sous-chemins.
31
+ * Les deux derniers sont donc une DÉFENSE EN PROFONDEUR, pas un correctif :
32
+ * ils gardent cette liste symétrique de celle du développement, et nous
33
+ * rendent indépendants d'un détail d'implémentation de plugin tiers qui
34
+ * peut disparaître à une majeure sans que personne ne le remarque.
35
+ *
36
+ * @param usedPresets - les préréglages effectivement présents dans les entrées
37
+ * @returns les spécificateurs à passer à `resolve.dedupe`, dans l'ordre
38
+ */
39
+ export declare function resolveDedupe(usedPresets: ReadonlySet<IFrontPreset["type"]>): string[];
3
40
  /**
4
41
  * Construit la config Vite finale à partir des entrées résolues et des presets.
5
42
  *