Accueil / Articles

Mises à jour OTA auto-hébergées pour React Native basique avec hot-updater et Supabase

Mettre en place des mises à jour JavaScript sans connexion dans une application React Native basique avec hot-updater et Supabase : initialisation, un hook de mise à jour silencieuse, des canaux, des scripts de déploiement et un mécanisme de réversion.

Éditeur : REACTAPP.TOP12 min de lecture2830 mots

Sources: Ship JavaScript Fixes in Minutes, Not Days: OTA Updates in React Native with Hot Updater

Une correction en JavaScript en une seule ligne dans une application React Native ne devrait pas nécessiter des jours d’attente pour être examinée par l’App Store, or c’est précisément ce qui se produit lorsque chaque modification est livrée via un nouveau fichier binaire. Les mises à jour par air (OTA) permettent de délivrer directement des modifications en JavaScript pur aux applications installées, tandis que les modifications natives doivent encore passer par les stores. Ce guide présente une configuration de niveau production basée sur la bibliothèque open source hot-updater, avec un backend Supabase auto-hébergé : installation, initialisation unique, un mécanisme de mise à jour silencieuse avec une demande personnalisée, des canaux par build, des scripts de déploiement reproductibles ainsi que des contrôles de rollback qui assurent la sécurité de l’ensemble.

Le scénario de référence concerne une application logistique utilisant React Native pur sur la Nouvelle Architecture (version 0,85 au moment de la rédaction), où les chauffeurs ont besoin de corrections rapidement et où personne ne souhaite dépendre de l’outilchain Expo.

Ce que met réellement à jour une mise à jour OTA

Chaque binaire React Native est en réalité composé de deux éléments emballés ensemble :

  • Codage natif : les shells Android et iOS, les modules natifs, les permissions, ainsi que tout ce qui se trouve dans android/ et ios/.
  • Le bundle JavaScript : les écrans, les composants, la logique métier, ainsi que la majeure partie des correctifs de bugs courants et des améliorations fonctionnelles.

Une publication dans une boutique de téléchargement fige ces deux éléments en un APK ou un IPA. Le problème est que la plupart des tâches quotidiennes ne concernent que le côté JavaScript. Un bouton mal étiqueté, un paramètre API incorrect ou une modification de mise en page ne nécessitent pas de nouveau codage natif, mais le processus de publication classique oblige néanmoins à une compilation complète et à un cycle d’examen.

La mise à jour par OTA sépare le paquet de ce cycle. Au lieu de considérer le JavaScript intégré comme permanent, l’application demande à un serveur d’actualisation s’un nouveau paquet existe, le télécharge et l’installe lors du prochain chargement. Aucune soumission à une boutique ni réinstallation n’est nécessaire ; la correction parvient aux utilisateurs lors du prochain lancement de l’application.

Une règle régit tout ce qui suit :

Changement uniquement en JavaScript : le transmettre par voie aérienne. Changement natif (une nouvelle dépendance native, une mise à jour de version, un nouveau module natif) : il faut encore une véritable compilation pour la boutique.

OTA ne remplace pas les compilations natives. Elle empêche ces dernières de bloquer les mises à jour JavaScript. Avant chaque déploiement OTA, vérifiez si quelque chose de natif a changé depuis le dernier binaire ; dans ce cas, le nouveau paquet pourrait provoquer des plantages sur les appareils qui exécutent encore l’ancien.

Choisir hot-updater par rapport aux alternatives

Pendant des années, Microsoft CodePush a été la solution par défaut. Avec le dépréciation d’App Center et de CodePush, les équipes ont dû en choisir une alternative. Les options réalistes se présentaient ainsi lors de l’évaluation (vérifiez l’état actuel de chacune avant de décider, car ce marché évolue rapidement) :

  • CodePush / App Center : dépréciés, donc pas une option.
  • EAS Update : mature et fiable, mais il lie davantage un projet basique à l’écosystème Expo.
  • Revopush et Codemagic CodePush : alternatives directes possibles, mais il s’agit de services payants.
  • Firebase Storage en tant que backend : uniquement disponible dans le plan payant Blaze.
  • hot-updater : une option open source et auto-hébergée, gratuite, qui prend en charge la Nouvelle Architecture.

hot-updater s’est avéré supérieur pour plusieurs raisons concrètes :

  • Aucun coût de licence : il s’agit d’un logiciel open source, sans facturation par MAU.
  • Soutien à la nouvelle architecture : exigence absolue pour une application utilisant cette nouvelle architecture.
  • Backend adaptable : le stockage et la base de données sont choisis via des plugins. Supabase convient parfaitement, car un seul projet fournit le stockage, Postgres ainsi qu’une fonction d’edge servant de serveur de mise à jour, tout cela dans le cadre d’un plan gratuit généreux.
  • Console web et CLI : vous n’êtes pas limité à des workflows uniquement en terminal.
  • Rollback instantané : un bundle défectueux peut être désactivé en un clic, sans avoir à le redéployer ni faire l’objet d’une révision par le stockage.
  • Déploiements progressifs et groupes ciblés : un bundle peut d’abord être déployé sur une petite partie des appareils, par exemple 1 %, puis progressivement jusqu’à 100 %, ou cibler un groupe spécifique comme qa.
  • Maintenance active : un signal important concernant l’infrastructure dont dépendra votre processus de publication.
  • Gardez à l’esprit une chose : la bibliothèque est gratuite, mais le stockage et la bande passante nécessaires pour vos fichiers sont de votre responsabilité. Pour une application de petite ou moyenne taille, le plan gratuit de Supabase suffit généralement ; cependant, pour une base d’utilisateurs importante téléchargeant fréquemment des fichiers, le calcul est différent.

    Intégration de hot-updater dans une application React Native basique

    L’intégration se compose de trois étapes : l’installation des paquets et la configuration du backend, l’initialisation de la bibliothèque une seule fois, ainsi que la mise en place du flux de mise à jour dans votre interface utilisateur.

    Installer les paquets et configurer Supabase

    Le paquet de runtime fait partie des dépendances régulières, tandis que la CLI, le plugin de construction pour workflow basique et le plugin Supabase relèvent des dépendances de développement.

    npm install @hot-updater/react-native
    npm install -D @hot-updater/bare @hot-updater/supabase hot-updater
    

    Ensuite, laissez l’interface en ligne de commande créer la structure du backend pour vous.

    npx hot-updater init
    

    La commande init configure le bucket de stockage, crée les tables nécessaires et publie la fonction d’edge update-server que l’application appellera. Elle écrit également un fichier .env contenant les identifiants. Ajoutez immédiatement ce fichier à .gitignore, et documentez la manière de le regénérer à partir des clés service-role de Supabase, car toute personne travaillant sur un clone vierge devra le recréer.

    Initialiser une seule fois au point d’entrée

    HotUpdater.init() doit se trouver dans le scope du module index.js et doit s’exécuter une seule fois, avant toute autre utilisation de la bibliothèque. C’est elle qui indique à hot-updater où chercher les mises à jour. Si elle est absente, chaque vérification ultérieure de mise à jour ne dispose d’aucune URL de serveur et agit silencieusement sans rien faire, ce qui constitue l’un des modes de défaillance les plus difficiles à diagnostiquer.

    Les imports permettent d’importer la bibliothèque, react-native-config pour les valeurs spécifiques à l’environnement, ainsi que le service d’analyse propre à l’application.

    // index.js
    import { HotUpdater } from '@hot-updater/react-native';
    import Config from 'react-native-config';
    import { analytics, AnalyticsEvent } from '@services';
    

    La configuration elle-même lit l’URL de la fonction d’edge et la stratégie de mise à jour depuis des variables d’environnement. Le gestionnaire onError est délibérément minimal : il s’exécute pendant le démarrage, il ne devrait donc pas dépendre de parties de l’application qui ne sont peut-être pas encore prêtes. Sa seule fonction est d’enregistrer que l’initialisation a échoué.

    HotUpdater.init({
      baseURL: Config.HOT_UPDATER_SUPABASE_FUNCTION_URL, // your edge function URL
      updateStrategy: Config.HOT_UPDATER_UPDATE_STRATEGY, // e.g. 'appVersion'
      onError: (error) => {
        // Bootstrap-level handler — keep it dependency-free.
        // Just enough to know init itself failed.
        if (__DEV__) console.log('🔥 [OTA] init error', error);
        analytics.track(AnalyticsEvent.OTA_INIT_ERROR, {
          message: String(error?.message ?? error),
        });
      },
    });
    

    Remarquez ici la séparation des préoccupations. La fonction init décide d’où proviennent les mises à jour. Le fait que l’application doive effectuer des vérifications relève de votre propre logique, par exemple via un indicateur fonctionnel distant. En gardant ces deux décisions distinctes, il devient facile de désactiver les mises à jour OTA dans un environnement sans toucher au code d’amorçage.

    Un crochet de mise à jour silencieux au lieu de wrap()

    La bibliothèque fournit un composant de haut niveau HotUpdater.wrap() qui gère automatiquement les vérifications, le téléchargement et l’application des mises à jour. Si tout ce dont vous avez besoin, c’est de « vérifier au démarrage, mettre à jour, terminé », utilisez-le ; c’est la solution la plus rapide.

    // useOtaUpdater.ts
    import { useCallback, useEffect, useRef, useState } from 'react';
    import { AppState, Modal } from 'react-native';
    import { HotUpdater } from '@hot-updater/react-native';
    
    export const useOtaUpdater = () => {
      const otaModalRef = useRef<Modal | null>(null);
      const stagedRef = useRef(false);   // a bundle is downloaded & waiting
      const busyRef = useRef(false);     // a check is in flight
      const [otaForced, setOtaForced] = useState(false);
      // 1. Check the server + stage the bundle in the background (no reload yet).
      const runSilentOtaCheck = useCallback(async () => {
        if (busyRef.current || stagedRef.current) return;
        busyRef.current = true;
        try {
          const updateInfo = await HotUpdater.checkForUpdate({
            updateStrategy: 'appVersion',
            requestTimeout: 20000, // default 5s is too tight for a cold edge function
          });
          if (!updateInfo) return; // null → up to date (or check failed)
          // Download & stage to disk - does NOT reload the app.
          await updateInfo.updateBundle();
          stagedRef.current = true;
          setOtaForced(!!updateInfo.shouldForceUpdate);
          otaModalRef.current?.openModal(); // ask the user to apply it
        } finally {
          busyRef.current = false;
        }
      }, []);
      // 2. User tapped "Update" → swap the JS bundle and restart.
      const applyOtaUpdate = useCallback(async () => {
        await HotUpdater.reload(); // this is what actually applies the update
      }, []);
      // 3. Triggers: check on launch and whenever the app returns to foreground.
      useEffect(() => {
        const timer = setTimeout(runSilentOtaCheck, 3000); // backstop launch check
        const sub = AppState.addEventListener('change', (s) => {
          if (s === 'active') runSilentOtaCheck();
        });
        return () => {
          clearTimeout(timer);
          sub.remove();
        };
      }, [runSilentOtaCheck]);
      return { otaModalRef, otaForced, applyOtaUpdate };
    };
    

    • requestTimeout: 20000 : le délai d’attente par défaut est de 5 secondes. Une fonction edge Supabase en état froid met souvent plus de temps, ce qui provoque un délai d’attente et fait que checkForUpdate renvoie null. Ce résultat est indiscernable d’un état « déjà à jour », de sorte que les mises à jour n’arrivent jamais. En augmentant le délai d’attente à 20 secondes, on compense le retard lié au démarrage en froid.
    • D’abord le stade, puis l’application : updateBundle() télécharge le nouveau bundle sur le disque sans recharger quoi que ce soit. Seule la fonction reload() permet de l’appliquer réellement. En séparant ces deux étapes, un pilote en cours de livraison n’est jamais interrompu par une redémarrage soudain ; le téléchargement a lieu en arrière-plan et l’utilisateur décide du moment de son application.
  • Un point d’entrée protégé : chaque déclencheur, que ce soit le lancement, le retour en premier plan ou la réconnexion, appelle la même fonction, et cette fonction s’arrête prématurément lorsque busyRef ou stagedRef est défini. Les deux vérifications ne s’exécutent jamais simultanément, et un paquet déjà préparé n’est jamais téléchargé à nouveau.
  • Deux petits points méritent d’être notés. La vérification différée de trois secondes agit comme filet de sécurité pour le lancement, au cas où le premier événement d’état active se produirait avant que l’hook ne s’abonne. De plus, l’appel à openModal() suppose que le référentiel pointe vers un composant modal personnalisé qui expose cette méthode, et non vers le Modal intégré de React Native ; adaptez donc le type de référentiel à votre propre composant.

    Montez l’hook et affichez la demande

    Appelez le crochet situé près de la racine de l’application et affichez un seul modal qui gère les deux types de mises à jour. Une mise à jour optionnelle peut être annulée ; une mise à jour forcée cache le bouton d’annulation et ne peut pas être fermée.

    // App.tsx
    const { otaModalRef, otaForced, applyOtaUpdate } = useOtaUpdater();
    
    return (
      <InfoModal
        modalRef={otaModalRef}
        subText={otaForced ? 'A required update is available.' : 'An update is available.'}
        onAcceptPress={applyOtaUpdate}
        showCancelButton={!otaForced}  // forced updates can't be dismissed
        dismissable={!otaForced}
      />
    );
    

    Puisque shouldForceUpdate provient du serveur, vous décidez pour chaque bundle si les utilisateurs peuvent la différer. Réservez les mises à jour forcées aux correctifs qui ne peuvent vraiment pas attendre.

    Affectez chaque version native à un canal

    Le dernier élément indique à chaque binaire auquel canal il appartient, par exemple development, stage ou production. Comme la valeur est compilée dans le binaire, le serveur de mise à jour peut déterminer quelle séquence de bundles doit être reçue. Pour Android, définez une valeur HOT_UPDATER_CHANNEL pour chaque variante de produit dans le manifeste ; sur iOS, faites-le pour chaque cible dans Info.plist. Configurez-la une fois par variante ou par cible, et elle restera inchangée.

    Deux pièges touchent presque tout le monde. OTA ne fonctionne que sur les versions publiées, pas sur les versions de débogage. De plus, chaque nouvelle version native nécessite un déploiement pour être enregistrée comme bundle de base ; sans cela, le prochain déploiement affichera Skipped (no compatible base bundles).

    Déploiement d’une mise à jour depuis la ligne de commande

    Lorsque tout est connecté, publier une modification JavaScript ne nécessite qu’un seul commandement.

    npx hot-updater deploy -p android -t 3.0.0 -c development
    

    Les flags signifient ce qui suit :

    • -p : la plateforme, android ou ios.
    • -t : la version de l’application cible avec laquelle le bundle est compatible, ce qui correspond à votre versionName natif.
    • -c : le canal vers lequel publier.
    • -f : optionnel ; indique que la mise à jour doit être appliquée immédiatement.

    Enregistrez votre JavaScript, exécutez le déploiement, et la prochaine fois que quelqu’un ouvrira une version publiée sur ce canal, la mise à jour sera déjà prête.

    Encadrer les déploiements dans des scripts par environnement

    Taper cette commande pour deux plateformes sur trois canaux devient rapidement fastidieux, et il est facile de publier par erreur sur le mauvais canal. Un petit script Node alimenté par des variables d’environnement, exposées via npm scripts, élimine ces deux problèmes.

    Les entrées de package.json définissent le canal ainsi qu’un indicateur facultatif de force, puis appellent le même script.

    {
      "scripts": {
        "ota:dev":   "OTA_CHANNEL=development node scripts/ota-deploy.mjs",
        "ota:stage": "OTA_CHANNEL=stage       node scripts/ota-deploy.mjs",
        "ota:prod":  "OTA_CHANNEL=production  node scripts/ota-deploy.mjs",
        "ota:dev:force":   "OTA_CHANNEL=development OTA_FORCE=true node scripts/ota-deploy.mjs",
        "ota:stage:force": "OTA_CHANNEL=stage       OTA_FORCE=true node scripts/ota-deploy.mjs",
        "ota:prod:force":  "OTA_CHANNEL=production  OTA_FORCE=true node scripts/ota-deploy.mjs"
      }
    }
    

    Dans scripts/ota-deploy.mjs, l’environnement fournit à la fois le canal et l’option de force ; le script extrait ensuite la version cible depuis android/app/build.gradle sauf si OTA_VERSION la remplace, puis déploie les deux plateformes séquentiellement. execSync avec stdio: 'inherit' affiche les résultats de la ligne de commande dans votre terminal et lance une erreur en cas de sortie différente de zéro, de sorte qu’un déploiement Android échoué arrête l’exécution avant même d’essayer iOS.

    import { execSync } from 'node:child_process';
    import { readFileSync } from 'node:fs';
    // Pull the native versionName straight from build.gradle so -t is never stale.
    function getVersion() {
      if (process.env.OTA_VERSION) return process.env.OTA_VERSION;
      const gradle = readFileSync('./android/app/build.gradle', 'utf8');
      const match = gradle.match(/versionName\s+["']([^"']+)["']/);
      if (!match) throw new Error('Could not find versionName in build.gradle');
      return match[1];
    }
    const version = getVersion();
    const channel = process.env.OTA_CHANNEL ?? 'development';
    const force = process.env.OTA_FORCE === 'true' ? '-f' : '';
    for (const platform of ['android', 'ios']) {
      const cmd = `npx hot-updater deploy -p ${platform} -t ${version} -c ${channel} ${force}`.trim();
      execSync(cmd, { stdio: 'inherit' });
    }
    

    Le flux de travail quotidien se réduit alors à un petit nombre de commandes.

    npm run ota:dev     # ship to the dev channel, both platforms
    npm run ota:prod    # ship to production
    npm run ota:prod:force   # ship to production, force immediately
    

    Obtenir la version via Gradle au lieu de la saisir manuellement élimine toute une catégorie d’erreurs de publication : il est impossible de publier accidentellement un paquet correspondant à une version cible obsolète. Cependant, il faut être conscient de l’hypothèse sous-jacente. Le script utilise la propriété versionName d’Android pour les deux plateformes, il ne fonctionne donc que si la version de marketing pour iOS est maintenue en synchronisation. Si ces deux versions peuvent diverger, il faut lire la version iOS séparément ou définir explicitement OTA_VERSION. La syntaxe VAR=value intégrée dans les scripts npm suppose également l’usage d’un shell POSIX ; sous Windows, il faudrait utiliser un outil comme cross-env.

    Le rollback et les déploiements par étapes comme filet de sécurité

    Résoudre rapidement le problème n’est que la moitié du travail. L’autre moitié consiste à limiter l’impact négatif lorsque un paquet s’avère défectueux, et c’est là que la console hot-updater devient indispensable.

    Rétrogradation instantanée

    Chaque paquet listé dans la console dispose d’un interrupteur Activé. Si un paquet récemment déployé est défectueux, désactivez-le. Les appareils qui l’ont déjà installé reviendront au dernier paquet fonctionnel lors de leur prochaine vérification, sans avoir à le réinstaller, à le redéployer ou à attendre Apple ou Google. La CLI offre la même solution via hot-updater rollback, qui permet de revenir au paquet le plus récemment déployé.

    C’est la raison principale pour laquelle on peut compter sur les mises à jour OTA : corriger une erreur ne prend que quelques secondes. Notez que la rétrogradation n’a d’effet que lorsque les appareils effectuent à nouveau une vérification, donc le déclencheur de vérification en arrière-plan est tout aussi important pour la récupération que pour la livraison des mises à jour.

    Lancement progressif et groupes cibles

    Au lieu de déployer un paquet sur tous les appareils en même temps, vous pouvez d’abord le lancer à une petite proportion d’appareils, examiner les rapports de panne et les analyses, puis augmenter progressivement la part jusqu’à atteindre 100 %. Les groupes cibles vous permettent de viser un groupe spécifique, comme un groupe qa interne, avant que quiconque d’autre ne voie le changement. Associé au bouton de activation, cela transforme un déploiement risqué en une expérience contrôlée. Consultez la documentation de hot-updater pour connaître les options de configuration exactes, car elles peuvent varier d’une version à l’autre.

    Points clés

    • Considérez OTA comme une voie rapide uniquement pour JavaScript ; tout ce qui touche au code natif nécessite toujours une compilation dans le magasin, et chaque nouvelle compilation doit être enregistrée comme un paquet de base.
  • Appelez HotUpdater.init() une seule fois dans index.js, et séparez le « lieu où vérifier » du « fait de vérifier ».
  • Augmentez la valeur de requestTimeout lorsque votre serveur d’actualisation est une fonction serverless, car une vérification dépassant le délai ressemble exactement à « aucune mise à jour disponible ».
  • Téléchargez les données avec updateBundle() et appliquez-les avec reload() séparément, afin que les utilisateurs ne soient jamais interrompus au milieu d’une tâche.
  • Scriptez vos déploiements et déterminez la version cible à partir du projet natif pour éviter les erreurs de canal ou de version incorrects.
  • Planifiez le processus de récupération avant la première publication : identifiez l’endroit où se trouve le bouton d’activation, et commencez les modifications à risque par un déploiement progressif limité.