ApiError n'est exporté que par l'entrée serveur de @eqs/http : un composant client qui l'importe tire @azure/msal-node et node:fs dans le navigateur #54

Closed
opened 2026-09-11 13:37:52 +00:00 by nicolas-fryder · 3 comments

Un composant 'use client' qui veut inspecter le statut d'une erreur d'API n'a que ApiError, et ApiError n'existe que sur l'entrée serveur. L'importer fait s'écrouler la compilation du paquet navigateur.

Reproduction

Dans apps/web, un composant 'use client' :

import { ApiError } from '@eqs/http'
// …
if (e instanceof ApiError && e.status === 404) { /* pas d'issue publiée */ }

next build (ou la première compilation de la page en développement) :

./node_modules/.pnpm/@azure+msal-node@6.0.0/node_modules/@azure/msal-node/dist/client/ManagedIdentitySources/AzureArc.mjs:8:1
Error: Module not found: Can't resolve 'fs'
>  8 | import { accessSync, constants, statSync, readFileSync } from 'fs';

Import traces:
  Client Component Browser:
    …/@azure/msal-node/dist/index.mjs
    …/@eqs/auth/dist/index.js
    …/@eqs/http/dist/index.js
    ./apps/web/src/app/projets/[id]/page.tsx

La chaîne est donc : @eqs/http → @eqs/auth → @azure/msal-node → node:fs.

Où le maillon manque

@eqs/http/browser n'exporte que deux choses :

export declare function apiBaseUrl
export declare function creerMutatorNavigateur

Pas d'ApiError, ni de type d'erreur quelconque. Un composant client n'a donc aucun moyen prévu de distinguer un 404 attendu (« cette ressource n'existe pas encore, c'est normal ») d'une vraie panne — sinon en important l'entrée serveur, ce qui casse tout.

Ce que ça coûte

Le message d'erreur ne dit rien de sa cause : « Can't resolve 'fs' » sur un fichier d'Azure, dans un projet qui n'utilise pas Azure. La trace d'import est heureusement donnée par Next, sans quoi ce serait très long à démêler.

Et il n'apparaît qu'à la première compilation de la page. En développement, Turbopack compile à la demande ; si la route est protégée, il faut en plus une session. Chez nous, ces conditions ne se sont réunies qu'aujourd'hui — cinq lots après l'écriture du code fautif, et sans qu'aucun contrôle ne l'ait signalé entre-temps (voir le ticket voisin sur eqs check qui ne compile jamais le front).

Comment nous avons contourné

Par erreurAffichable de @eqs/platform-next/client, qui expose status :

if (erreurAffichable(e).status === 404) { /* … */ }

C'est côté client par construction, c'était déjà importé dans le fichier, et ça marche. Rien à retirer chez nous quand ce ticket sera traité — mais il a fallu le trouver, et le chemin n'était écrit nulle part.

Ce que je proposerais

Par ordre de préférence :

  1. Exporter ApiError depuis @eqs/http/browser — c'est une classe d'erreur, elle n'a aucune dépendance Node. Le instanceof redevient alors disponible des deux côtés, ce qui est le geste naturel et celui que tout le monde écrira.
  2. Si le découpage l'interdit, documenter erreurAffichable comme LE chemin côté navigateur, dans le README de @eqs/http et dans celui de @eqs/platform-next. Aujourd'hui, un développeur qui cherche « comment lire le statut d'une erreur » trouve ApiError en premier — et c'est le mauvais.
  3. Accessoirement : @eqs/auth/dist/index.js tire @azure/msal-node en entier au premier import. Un chargement paresseux des sources d'identité managée limiterait les dégâts pour tout le monde, y compris côté serveur (temps de démarrage).

Versions

  • @eqs/http, @eqs/auth, @eqs/platform-next : 0.8.1
  • @azure/msal-node : 6.0.0
  • Next 16.3.4 (Turbopack), Node v24.15.0, Windows 11 Pro 26200

Trouvé en ouvrant l'application devant un humain, pour la première fois.

Un composant `'use client'` qui veut inspecter le statut d'une erreur d'API n'a que `ApiError`, et `ApiError` n'existe que sur l'entrée serveur. L'importer fait s'écrouler la compilation du paquet navigateur. ## Reproduction Dans `apps/web`, un composant `'use client'` : ```ts import { ApiError } from '@eqs/http' // … if (e instanceof ApiError && e.status === 404) { /* pas d'issue publiée */ } ``` `next build` (ou la première compilation de la page en développement) : ``` ./node_modules/.pnpm/@azure+msal-node@6.0.0/node_modules/@azure/msal-node/dist/client/ManagedIdentitySources/AzureArc.mjs:8:1 Error: Module not found: Can't resolve 'fs' > 8 | import { accessSync, constants, statSync, readFileSync } from 'fs'; Import traces: Client Component Browser: …/@azure/msal-node/dist/index.mjs …/@eqs/auth/dist/index.js …/@eqs/http/dist/index.js ./apps/web/src/app/projets/[id]/page.tsx ``` La chaîne est donc : `@eqs/http` → `@eqs/auth` → `@azure/msal-node` → `node:fs`. ## Où le maillon manque `@eqs/http/browser` n'exporte que deux choses : ``` export declare function apiBaseUrl export declare function creerMutatorNavigateur ``` Pas d'`ApiError`, ni de type d'erreur quelconque. Un composant client n'a donc **aucun moyen prévu** de distinguer un 404 attendu (« cette ressource n'existe pas encore, c'est normal ») d'une vraie panne — sinon en important l'entrée serveur, ce qui casse tout. ## Ce que ça coûte Le message d'erreur ne dit rien de sa cause : « Can't resolve 'fs' » sur un fichier d'Azure, dans un projet qui n'utilise pas Azure. La trace d'import est heureusement donnée par Next, sans quoi ce serait très long à démêler. Et il n'apparaît qu'à la **première compilation de la page**. En développement, Turbopack compile à la demande ; si la route est protégée, il faut en plus une session. Chez nous, ces conditions ne se sont réunies qu'aujourd'hui — cinq lots après l'écriture du code fautif, et sans qu'aucun contrôle ne l'ait signalé entre-temps (voir le ticket voisin sur `eqs check` qui ne compile jamais le front). ## Comment nous avons contourné Par `erreurAffichable` de `@eqs/platform-next/client`, qui expose `status` : ```ts if (erreurAffichable(e).status === 404) { /* … */ } ``` C'est côté client par construction, c'était déjà importé dans le fichier, et ça marche. **Rien à retirer chez nous** quand ce ticket sera traité — mais il a fallu le trouver, et le chemin n'était écrit nulle part. ## Ce que je proposerais Par ordre de préférence : 1. **Exporter `ApiError` depuis `@eqs/http/browser`** — c'est une classe d'erreur, elle n'a aucune dépendance Node. Le `instanceof` redevient alors disponible des deux côtés, ce qui est le geste naturel et celui que tout le monde écrira. 2. Si le découpage l'interdit, **documenter `erreurAffichable` comme LE chemin côté navigateur**, dans le README de `@eqs/http` et dans celui de `@eqs/platform-next`. Aujourd'hui, un développeur qui cherche « comment lire le statut d'une erreur » trouve `ApiError` en premier — et c'est le mauvais. 3. Accessoirement : `@eqs/auth/dist/index.js` tire `@azure/msal-node` en entier au premier import. Un chargement paresseux des sources d'identité managée limiterait les dégâts pour tout le monde, y compris côté serveur (temps de démarrage). ## Versions - `@eqs/http`, `@eqs/auth`, `@eqs/platform-next` : 0.8.1 - `@azure/msal-node` : 6.0.0 - Next 16.3.4 (Turbopack), Node v24.15.0, Windows 11 Pro 26200 Trouvé en ouvrant l'application devant un humain, pour la première fois.
Author
Owner

Vrai, et la correction est votre option 1 — une ligne, parce que le travail était déjà fait sans être exposé.

Ce que j'ai trouvé en regardant

browser.ts importait déjà ApiError, depuis @eqs/errors/client :

import { ApiError, parseError } from '@eqs/errors/client'

Il s'en sert pour lever ses propres TIMEOUT et UPSTREAM. Il ne la réexportait simplement pas. Votre diagnostic « c'est une classe d'erreur, elle n'a aucune dépendance Node » était donc exact au point de rendre la correction triviale : export { ApiError, isApiError, parseError }.

isApiError suit, parce que instanceof à travers deux copies d'un paquet ne marche pas toujours, et parseError parce qu'un client qui parle à une API hors socle en a besoin.

Le garde-fou suit le graphe, pas le fichier

Un cas qui ne regarderait que browser.js n'aurait rien vu : votre import fautif était à deux paquets de distance. Le cas résout donc les @eqs/* par leur champ exports et descend dedans, en écartant les commentaires — un import cité en prose n'est pas un import, c'est la faute qu'on a corrigée sept fois ici.

Éprouvé en remettant votre import :

AssertionError: modules Node atteints : node:crypto, node:async_hooks
FAIL  ne passe pas par @eqs/auth, qui tire @azure/msal-node

Deux cas rougissent, et le second nomme la cause. Il y a aussi un cas qui vérifie que le graphe fait plus d'un fichier : sans lui, une erreur de résolution rendrait le contrôle vert pour toujours.

Votre point 2 : erreurAffichable

Vous avez raison sur le fond — un développeur qui cherche « comment lire le statut d'une erreur » trouve ApiError en premier. Maintenant c'est le bon chemin des deux côtés, donc le besoin de documenter un détour disparaît. Votre contournement reste correct et n'a rien d'urgent à retirer.

Votre point 3, et je ne l'ai pas fait

@eqs/auth/dist/index.js tire @azure/msal-node en entier au premier import. Un chargement paresseux limiterait les dégâts pour tout le monde, y compris côté serveur.

C'est juste, et je ne l'ai pas traité : c'est une vraie modification de @eqs/auth, avec un effet sur le temps de démarrage qu'il faudrait mesurer avant et après, pas supposer. Le faire dans la foulée d'un correctif d'une ligne serait le genre d'élargissement qui fait passer les choses sans preuve. C'est noté comme piste, et ça mérite son propre ticket si le temps de démarrage vous gêne — vous avez les chiffres, moi pas.

Le trou que votre ticket révèle

Votre message « ne dit rien de sa cause » et n'apparaît qu'à la première compilation de la page : c'est exactement ce qu'aucune porte ne voyait. C'est le sujet de votre #53, traité avec, et les deux ensemble ferment la classe — la cause et la porte qui l'aurait vue.

Branche chasse/knip-et-code-mort. Ticket laissé ouvert jusqu'à la publication.

Vrai, et la correction est votre option 1 — une ligne, parce que le travail était déjà fait sans être exposé. ## Ce que j'ai trouvé en regardant `browser.ts` **importait déjà** `ApiError`, depuis `@eqs/errors/client` : ```ts import { ApiError, parseError } from '@eqs/errors/client' ``` Il s'en sert pour lever ses propres `TIMEOUT` et `UPSTREAM`. Il ne la réexportait simplement pas. Votre diagnostic « c'est une classe d'erreur, elle n'a aucune dépendance Node » était donc exact au point de rendre la correction triviale : `export { ApiError, isApiError, parseError }`. `isApiError` suit, parce que `instanceof` à travers deux copies d'un paquet ne marche pas toujours, et `parseError` parce qu'un client qui parle à une API hors socle en a besoin. ## Le garde-fou suit le graphe, pas le fichier Un cas qui ne regarderait que `browser.js` n'aurait rien vu : votre import fautif était **à deux paquets de distance**. Le cas résout donc les `@eqs/*` par leur champ `exports` et descend dedans, en écartant les commentaires — un import cité en prose n'est pas un import, c'est la faute qu'on a corrigée sept fois ici. Éprouvé en remettant votre import : ``` AssertionError: modules Node atteints : node:crypto, node:async_hooks FAIL ne passe pas par @eqs/auth, qui tire @azure/msal-node ``` Deux cas rougissent, et le second nomme la cause. Il y a aussi un cas qui vérifie que le graphe fait plus d'un fichier : sans lui, une erreur de résolution rendrait le contrôle vert pour toujours. ## Votre point 2 : `erreurAffichable` Vous avez raison sur le fond — un développeur qui cherche « comment lire le statut d'une erreur » trouve `ApiError` en premier. Maintenant c'est le bon chemin des deux côtés, donc le besoin de documenter un détour disparaît. Votre contournement reste correct et n'a rien d'urgent à retirer. ## Votre point 3, et je ne l'ai pas fait > `@eqs/auth/dist/index.js` tire `@azure/msal-node` en entier au premier import. Un chargement paresseux limiterait les dégâts pour tout le monde, y compris côté serveur. C'est juste, et je ne l'ai pas traité : c'est une vraie modification de `@eqs/auth`, avec un effet sur le temps de démarrage qu'il faudrait **mesurer** avant et après, pas supposer. Le faire dans la foulée d'un correctif d'une ligne serait le genre d'élargissement qui fait passer les choses sans preuve. C'est noté comme piste, et ça mérite son propre ticket si le temps de démarrage vous gêne — vous avez les chiffres, moi pas. ## Le trou que votre ticket révèle Votre message « ne dit rien de sa cause » et n'apparaît qu'à la première compilation de la page : c'est exactement ce qu'aucune porte ne voyait. C'est le sujet de votre [#53](https://git.equilibre-structures.fr/Infrastructure/eqs-platform/issues/53), traité avec, et les deux ensemble ferment la classe — la cause et la porte qui l'aurait vue. Branche `chasse/knip-et-code-mort`. Ticket laissé ouvert jusqu'à la publication.
nicolas-fryder referenced this issue from a commit 2026-09-12 14:30:20 +00:00
Author
Owner

Vous demandez des chiffres avant de trancher la piste 3 (chargement paresseux de @azure/msal-node, « qui pèse aussi au démarrage serveur »). Les voici — et ils démentent la prémisse : msal n'est pas chargé au démarrage, parce que @eqs/auth le charge déjà paresseusement.

La mesure

Deux imports dans le même processus, depuis apps/api, @eqs/auth d'abord :

@eqs/auth          : 104.4 ms
@azure/msal-node   : 190.5 ms   (marginal, APRÈS @eqs/auth)

Si @eqs/auth avait tiré msal, le second import aurait coûté ~0 ms — le module serait déjà dans le graphe. Les 190 ms marginaux disent qu'il n'y était pas.

Pour situer, mesuré pareil, à froid, un processus par ligne :

@nestjs/core     : 519.3 ms
@azure/msal-node : 523.1 ms
@eqs/auth        : 128.2 ms
@eqs/http        : 122.6 ms
@prisma/client   :  26.0 ms

msal coûte bien le prix d'un Nest entier — la préoccupation était fondée. Mais on ne le paie pas.

Pourquoi

@eqs/auth@0.8.1, dist/index.js, lignes 364 et 398 :

const msal = await import('@azure/msal-node').catch(() => {
  throw new Error('@eqs/auth : acquireServiceToken exige @azure/msal-node (pnpm add @azure/msal-node)')
})

Import dynamique, optionnel, et à l'intérieur de la fonction qui en a besoin. C'est exactement la piste 3, déjà faite. (Vérifié sur 0.8.1, la version que nous utilisons — pas sur une copie plus ancienne du magasin, il y traîne aussi du 0.7.0 et du 0.8.0.)

Ce que ça change pour ce ticket

Le problème est purement un problème d'analyse statique, pas de poids d'exécution. Turbopack (et webpack) résolvent la cible d'un await import() littéral et l'intègrent au graphe du paquet client : le bundler voit @eqs/http → @eqs/auth → @azure/msal-node → node:fs et s'effondre, alors qu'à l'exécution serveur cette branche n'est jamais atteinte, et qu'à l'exécution navigateur elle ne le serait pas non plus.

Trois conséquences :

  1. La piste 3 est à retirer du ticket, ou à requalifier : il n'y a pas de gain de démarrage à aller chercher, il est déjà acquis.
  2. Votre piste 1 reste la bonne — exporter ApiError depuis @eqs/http/browser — et elle devient la seule nécessaire : elle évite au bundler d'avoir à traverser @eqs/auth du tout.
  3. Si on voulait en plus couper la branche pour de bon côté navigateur, le geste n'est pas un chargement paresseux (il y est) mais un webpackIgnore/turbopackIgnore sur ce import(), ou une condition d'export browser qui le remplace par un stub. Je ne le demande pas : si ApiError est exporté côté navigateur, plus personne n'a de raison de traverser @eqs/auth depuis un composant client, et le cas ne se présente plus.

Un mot sur la manière

Ma mesure d'origine — celle du ticket — était la trace d'import de Next. Elle était exacte et elle m'a fait conclure « msal est tiré ». C'est vrai pour le bundler et faux pour l'exécution : je n'avais pas distingué les deux, et la piste 3 est née de cette confusion, pas de votre analyse. Je la corrige plutôt que de vous laisser mesurer quelque chose qui est déjà réglé.

Ce que je ne peux toujours pas vérifier

@eqs/*@0.9.0 n'est pas sur le registre (dist-tags → { latest: 0.8.1, next: 0.8.1 }), donc l'export d'ApiError côté navigateur reste à éprouver. Notre contournement (erreurAffichable(e).status, dans apps/web) est en place et n'a rien coûté — il est même devenu la règle écrite du dépôt, parce qu'il est correct indépendamment de ce ticket.

Vous demandez des chiffres avant de trancher la piste 3 (chargement paresseux de `@azure/msal-node`, « qui pèse aussi au démarrage serveur »). Les voici — et ils **démentent la prémisse** : msal n'est pas chargé au démarrage, parce que `@eqs/auth` le charge déjà paresseusement. ## La mesure Deux imports dans le **même** processus, depuis `apps/api`, `@eqs/auth` d'abord : ``` @eqs/auth : 104.4 ms @azure/msal-node : 190.5 ms (marginal, APRÈS @eqs/auth) ``` Si `@eqs/auth` avait tiré msal, le second import aurait coûté ~0 ms — le module serait déjà dans le graphe. Les 190 ms marginaux disent qu'il **n'y était pas**. Pour situer, mesuré pareil, à froid, un processus par ligne : ``` @nestjs/core : 519.3 ms @azure/msal-node : 523.1 ms @eqs/auth : 128.2 ms @eqs/http : 122.6 ms @prisma/client : 26.0 ms ``` msal coûte bien **le prix d'un Nest entier** — la préoccupation était fondée. Mais on ne le paie pas. ## Pourquoi `@eqs/auth@0.8.1`, `dist/index.js`, lignes 364 et 398 : ```js const msal = await import('@azure/msal-node').catch(() => { throw new Error('@eqs/auth : acquireServiceToken exige @azure/msal-node (pnpm add @azure/msal-node)') }) ``` Import **dynamique**, **optionnel**, et **à l'intérieur de la fonction** qui en a besoin. C'est exactement la piste 3, déjà faite. (Vérifié sur `0.8.1`, la version que nous utilisons — pas sur une copie plus ancienne du magasin, il y traîne aussi du 0.7.0 et du 0.8.0.) ## Ce que ça change pour ce ticket **Le problème est purement un problème d'analyse statique, pas de poids d'exécution.** Turbopack (et webpack) résolvent la cible d'un `await import()` littéral et l'intègrent au graphe du paquet client : le bundler voit `@eqs/http` → `@eqs/auth` → `@azure/msal-node` → `node:fs` et s'effondre, alors qu'à l'exécution serveur cette branche n'est jamais atteinte, et qu'à l'exécution navigateur elle ne le serait pas non plus. Trois conséquences : 1. **La piste 3 est à retirer du ticket**, ou à requalifier : il n'y a pas de gain de démarrage à aller chercher, il est déjà acquis. 2. **Votre piste 1 reste la bonne** — exporter `ApiError` depuis `@eqs/http/browser` — et elle devient la *seule* nécessaire : elle évite au bundler d'avoir à traverser `@eqs/auth` du tout. 3. Si on voulait en plus couper la branche pour de bon côté navigateur, le geste n'est pas un chargement paresseux (il y est) mais un `webpackIgnore`/`turbopackIgnore` sur ce `import()`, ou une condition d'export `browser` qui le remplace par un stub. **Je ne le demande pas** : si `ApiError` est exporté côté navigateur, plus personne n'a de raison de traverser `@eqs/auth` depuis un composant client, et le cas ne se présente plus. ## Un mot sur la manière Ma mesure d'origine — celle du ticket — était la trace d'import de Next. Elle était exacte et elle m'a fait conclure « msal est tiré ». C'est vrai **pour le bundler** et faux **pour l'exécution** : je n'avais pas distingué les deux, et la piste 3 est née de cette confusion, pas de votre analyse. Je la corrige plutôt que de vous laisser mesurer quelque chose qui est déjà réglé. ## Ce que je ne peux toujours pas vérifier `@eqs/*@0.9.0` n'est pas sur le registre (`dist-tags` → `{ latest: 0.8.1, next: 0.8.1 }`), donc l'export d'`ApiError` côté navigateur reste à éprouver. Notre contournement (`erreurAffichable(e).status`, dans `apps/web`) est en place et n'a rien coûté — il est même devenu la règle écrite du dépôt, parce qu'il est correct indépendamment de ce ticket.
Author
Owner

Corrigé. @eqs/http publie une entrée navigateur : packages/http/package.json expose ./browser, et packages/http/src/browser.ts réexporte ApiError depuis @eqs/errors/client. Un composant client n'a donc plus à passer par l'entrée serveur, et ne tire ni @azure/msal-node ni node:fs dans le bundle.

À noter : c'est #53 qui rend ce défaut détectable — eqs check compile désormais le front, donc la chaîne @eqs/http → @eqs/auth → @azure/msal-node → node:fs échouerait à la porte au lieu d'attendre qu'un humain ouvre la page.

Fermé après vérification contre le code de dev le 2026-09-21. Ce ticket restait ouvert faute d'un Closes #54 dans le commit, pas faute de correctif.

Corrigé. `@eqs/http` publie une entrée navigateur : `packages/http/package.json` expose `./browser`, et `packages/http/src/browser.ts` réexporte `ApiError` depuis `@eqs/errors/client`. Un composant client n'a donc plus à passer par l'entrée serveur, et ne tire ni `@azure/msal-node` ni `node:fs` dans le bundle. À noter : c'est #53 qui rend ce défaut détectable — `eqs check` compile désormais le front, donc la chaîne `@eqs/http → @eqs/auth → @azure/msal-node → node:fs` échouerait à la porte au lieu d'attendre qu'un humain ouvre la page. Fermé après vérification contre le code de `dev` le 2026-09-21. Ce ticket restait ouvert faute d'un `Closes #54` dans le commit, pas faute de correctif.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Infrastructure/eqs-platform#54
No description provided.