Aller au contenu
Sylvain Joffraud l4w
Retour

supabase-js : « native WebSocket not found », Node 22 requis

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 :

EmplacementAvantAprès
Dockerfiles (build et production)node:20-slimnode:22-slim
engines.node (package.json)20.x22.x
.nvmrc2022
CI (actions/setup-node)node-version: '20''22'
Blueprint de l’hébergeur historiqueversion non déclaréeNODE_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

Questions fréquentes

Que signifie l’erreur « Node.js detected but native WebSocket not found » ?
Cette erreur vient de @supabase/realtime-js, embarqué par @supabase/supabase-js. À la création du client, la bibliothèque cherche un WebSocket global natif ; Node 20 n’en expose pas par défaut, Node 22 oui. Depuis supabase-js 2.116, l’absence de ce constructeur lève une exception dès createClient(), même si l’application n’utilise jamais le temps réel.
supabase-js fonctionne-t-il encore sous Node 20 ?
supabase-js 2.116 ne démarre plus sous Node 20 sans configuration : createClient() lève « native WebSocket not found ». La correction propre consiste à passer le runtime en Node 22 partout : image Docker, engines.node, .nvmrc, CI et plateforme d’hébergement. Rester sur une version antérieure de supabase-js ne fait que repousser le problème.
Pourquoi un bump Dependabot mineur peut-il casser la production ?
Une montée de version mineure respecte l’API publique au sens semver, mais rien n’interdit à une bibliothèque de relever sa version minimale de Node. Dependabot regarde les versions des paquets, pas le runtime sur lequel ils tourneront. Si la CI ne démarre jamais l’application sur la même image que la production, ce changement d’exigence passe inaperçu jusqu’au déploiement.
Pourquoi une exception dans un constructeur NestJS empêche-t-elle toute l’application de démarrer ?
NestJS instancie tous les providers pendant le bootstrap, avant d’ouvrir le port HTTP. Une exception levée dans le constructeur d’un service injectable remonte au ExceptionHandler et le processus s’arrête : aucune route ne répond, pas même le healthcheck. Un effet de bord réseau dans un constructeur transforme donc une panne de dépendance en panne totale.

Partager cet article :

Article précédent
JWT Supabase en ES256 : 401 avec Passport NestJS
Article suivant
Webhook Stripe et confirmation client : billets en double