L’erreur « Node.js detected but native WebSocket not found » a tué le conteneur de l’API d’une billetterie événementielle à chaque démarrage, deux déploiements de suite, pendant la migration vers une nouvelle plateforme d’hébergement. La cause : @supabase/supabase-js 2.116 exige un WebSocket natif, absent de Node 20, et le client Supabase était créé dans le constructeur d’un service NestJS. Le correctif tient en un changement de version de Node. La leçon, elle, concerne la façon dont un bump Dependabot anodin arrive jusqu’en production.
Le symptôme : « native WebSocket not found » au démarrage
Le build de l’image réussissait. C’est le démarrage du conteneur qui échouait, avec cette trace (chemin du service généralisé) :
ERROR [ExceptionHandler] Node.js detected but native WebSocket not found.
Suggested solution: Ensure you are running Node.js 22+
at WebSocketFactory.getWebSocketConstructor (@supabase/realtime-js/…)
at createClient (@supabase/supabase-js/…)
at new AuthAdminService (/app/dist/auth/auth-admin.service.js:25:60)
La pile : NestJS 10.4, TypeScript, Prisma, et @supabase/supabase-js utilisé uniquement côté serveur pour l’API d’administration des comptes (auth.admin). Aucun usage du temps réel. Les deux Dockerfiles partaient de node:20-slim.
La fausse piste : accuser la nouvelle plateforme
Le crash est apparu pendant une migration d’hébergement, sur des images Docker toutes neuves. J’ai donc d’abord soupçonné la conteneurisation, l’environnement injecté, la plateforme elle-même. Pour une fois, le message d’erreur ne mentait pas : il donnait la solution en toutes lettres. C’est mon hypothèse qui était fausse, à rebours du réflexe décrit dans un message d’erreur est une hypothèse, pas un diagnostic.
La vérité était moins flatteuse : la branche principale aurait crashé sur n’importe quel hébergeur en Node 20. L’ancien hébergeur tournait simplement sur une version antérieure du code, jamais redéployée depuis la montée de version. Le conteneur n’a rien causé ; il a seulement été le premier à exécuter le code récent.
Une migration d’hébergement ne crée pas les bugs : elle exécute enfin le code que personne n’avait redéployé.
D’où vient l’exigence Node 22 ?
La montée de version est arrivée par une PR Dependabot groupée (groupe prod-minor-patch, dépendances de production, mises à jour mineures et correctives) : @supabase/supabase-js de 2.103.3 à 2.116.0, avec trois autres paquets. Un bump mineur, donc réputé sans risque au sens semver.
Le piège est dans les dépendances transitives. supabase-js embarque @supabase/realtime-js, le client temps réel, qui résout un constructeur WebSocket dès la création du client. Node 22 expose un WebSocket global natif ; Node 20 non. Mon code n’ouvrait aucun WebSocket, mais la vérification tombe quand même.
Et rien dans la CI ne démarrait l’application : lint, tsc --noEmit, tests unitaires (où le service est remplacé par un mock) et nest build. Tout était vert sous Node 20, parce qu’aucune étape n’appelait le vrai createClient().
Pourquoi un constructeur tue tout le bootstrap
NestJS instancie tous les providers au démarrage, avant d’ouvrir le port HTTP. Le service ressemblait à ceci :
@Injectable()
export class AuthAdminService {
private client: SupabaseClient | null = null;
constructor(config: ConfigService) {
const url = config.get<string>('SUPABASE_URL');
const key = config.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (url && key) {
// Lève sous Node 20 : l'exception remonte au bootstrap
this.client = createClient(url, key, {
auth: { autoRefreshToken: false, persistSession: false },
});
}
}
}
Une exception dans ce constructeur n’est pas une erreur de fonctionnalité : c’est une panne totale. Aucune route ne répond, pas même /api/health. Un service secondaire (retrouver l’e-mail d’un utilisateur) a suffi à empêcher toute la billetterie de démarrer.
Le correctif : épingler Node 22 partout à la fois
Le changement de version a porté sur tous les endroits qui déclarent un runtime, dans le même commit :
| Emplacement | Avant | Après |
|---|---|---|
| Dockerfiles (build et production) | node:20-slim | node:22-slim |
engines.node (package.json) | 20.x | 22.x |
.nvmrc | 20 | 22 |
CI (actions/setup-node) | node-version: '20' | '22' |
| Blueprint de l’hébergeur historique | version non déclarée | NODE_VERSION: 22 |
La dernière ligne a été ajoutée le lendemain : l’hébergeur historique choisissait lui-même sa version de Node, et engines.node seul ne garantit pas la façon dont une plateforme l’interprète. Vérification en local, image de production construite puis lancée : Nest application successfully started, puis GET /api/health en 200. La nouvelle plateforme avait aussi ses propres pièges de build et de healthcheck, qui feront l’objet d’un autre article.
Lessons learned
- Un bump mineur peut relever l’exigence de runtime. Semver protège l’API d’un paquet, pas sa version minimale de Node, et la contrainte arrive souvent par une dépendance transitive.
- La version de Node se change partout à la fois ou nulle part. Image Docker,
engines.node,.nvmrc, CI et configuration de l’hébergeur doivent changer ensemble, dans le même commit. - La CI doit démarrer l’image de production, pas seulement la compiler. Un
docker runsuivi d’un appel au healthcheck aurait attrapé ce crash le jour du merge de la PR Dependabot. - Un effet de bord réseau dans un constructeur NestJS transforme une panne locale en panne totale. Créer le client à la demande, ou isoler l’erreur, garde l’API debout quand une dépendance secondaire casse.
- Une migration d’hébergement révèle les bugs plus qu’elle n’en crée. Avant d’accuser la nouvelle plateforme, vérifier si l’ancienne exécute vraiment le même code.