Inicio / Artículos

Actualizaciones OTA autohospedadas para Bare React Native con hot-updater y Supabase

Configurar actualizaciones de JavaScript sin conexión en una aplicación React Native básica con hot-updater y Supabase: inicialización, un gancho de actualización silenciosa, canales, scripts de despliegue y reversión.

Editor: REACTAPP.TOP12 min de lectura2830 palabras

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

Una corrección en un solo línea de JavaScript en una aplicación React Native no debería tener que esperar días a la revisión de App Store, pero eso es exactamente lo que sucede cuando cada cambio se envía a través de un nuevo binario. Las actualizaciones over-the-air (OTA) permiten entregar cambios en JavaScript puro directamente a las aplicaciones instaladas, mientras que los cambios nativos siguen pasando por las tiendas. Esta guía explica cómo configurar un entorno de nivel profesional basado en la biblioteca de código abierto hot-updater junto con un backend Supabase autohospedado: instalación, inicialización única, un mecanismo de actualización silenciosa con una solicitud personalizada, canales por compilación, scripts de despliegue reutilizables y controles de reversión que garantizan la seguridad del proceso.

El escenario de referencia es una aplicación logística que utiliza React Native puro en la Nueva Arquitectura (versión 0.85 al momento de escribir este texto), donde los conductores necesitan correcciones rápidamente y nadie quiere depender de la herramientachain Expo.

Qué reemplaza realmente una actualización OTA

Cada binario de React Native en realidad está formado por dos componentes empaquetados juntos:

  • Código nativo: las capas de Android e iOS, los módulos nativos, los permisos y todo lo que se encuentra en android/ y ios/.
  • El paquete de JavaScript: las pantallas, los componentes, la lógica empresarial, así como la mayor parte de las correcciones de errores habituales y los trabajos relacionados con nuevas funcionalidades.

Un lanzamiento a través de la tienda convierte ambos componentes en un APK o IPA. El problema es que la mayor parte del trabajo diario solo afecta al lado de JavaScript. Un botón mal etiquetado, un parámetro de API incorrecto o un ajuste en el diseño no requieren nuevo código nativo, pero el proceso de lanzamiento tradicional sigue obligando a una compilación completa y a un ciclo de revisión.

La actualización por OTA separa el paquete de ese ciclo. En lugar de tratar el JavaScript incrustado como permanente, la aplicación consulta a un servidor de actualizaciones para saber si existe un paquete más reciente, lo descarga y lo sustituye al siguiente recargue. No se requiere enviarlo a una tienda ni reinstalar la aplicación; la corrección llega a los usuarios al siguiente inicio de la app.

Hay una regla que rige todo lo que sigue:

Cambio solo en JavaScript: envíelo mediante actualización inalámbrica. Cambio nativo (una nueva dependencia nativa, un aumento de versión, un nuevo módulo nativo): aún se necesita una compilación real para la tienda.

OTA no reemplaza las compilaciones nativas. Impide que estas impidan la publicación de actualizaciones en JavaScript. Antes de cada despliegue por OTA, verifica si ha habido algún cambio en lo nativo desde el último binario; de ser así, el nuevo paquete podría causar fallos en dispositivos que siguen usando el anterior.

Elegir hot-updater en lugar de las alternativas

Durante años, Microsoft CodePush fue la opción por defecto. Con App Center y CodePush en desuso, los equipos tuvieron que elegir un sustituto. Las opciones realistas durante la evaluación eran las siguientes (verifique el estado actual de cada una antes de decidir, ya que este mercado cambia rápidamente):

  • CodePush / App Center: en desuso, por lo que no es una opción.
  • EAS Update: maduro y fiable, pero vuelve a vincular más estrechamente un proyecto básico con el ecosistema de Expo.
  • Revopush y Codemagic CodePush: sustitutos adecuados, pero son servicios de pago.
  • Firebase Storage como backend: solo está disponible en el plan de pago Blaze.
  • hot-updater: una opción de código abierto y autohospedable sin costo que admite la Nueva Arquitectura.

hot-updater resultó ser la mejor opción por varias razones concretas:

  • Sin costo de licencia: es de código abierto, sin facturación por usuario activo mensual.
  • Soporte para la nueva arquitectura: requisito indispensable para una aplicación con la nueva arquitectura habilitada.
  • Backend conectable: el almacenamiento y la base de datos se seleccionan mediante plugins. Supabase es una buena opción porque un único proyecto proporciona almacenamiento, Postgres y una función de edge que funciona como servidor de actualizaciones, todo dentro de un generoso plan gratuito.
  • Consola web más CLI: no está limitado a flujos de trabajo exclusivamente en terminal.
  • Retroceso instantáneo: un paquete defectuoso puede desactivarse con un simple cambio, sin necesidad de volver a desplegarlo ni revisarlo en el almacenamiento.
  • Lanzamientos por fases y grupos específicos: un paquete puede aplicarse primero a una pequeña parte de los dispositivos, como el 1%, y luego aumentar progresivamente al 100%, o dirigirse a un grupo específico como qa.
  • Mantenimiento activo: una señal importante para la infraestructura de la que dependerá su proceso de publicación.
  • Tenga en cuenta una advertencia: la biblioteca es gratuita, pero el almacenamiento y ancho de banda para sus paquetes son su responsabilidad. Para una aplicación pequeña o de tamaño medio, la versión gratuita de Supabase suele ser suficiente; sin embargo, cuando hay una gran base de usuarios que descarga paquetes con frecuencia, la situación es diferente.

    Integrar hot-updater en una aplicación React Native básica

    La integración consta de tres partes: instalar los paquetes y configurar el backend, inicializar la biblioteca una vez y establecer el flujo de actualización en su interfaz de usuario.

    Instalar los paquetes y configurar Supabase

    El paquete de tiempo de ejecución se incluye entre las dependencias habituales, mientras que la CLI, el plugin de compilación para flujos básicos y el plugin de Supabase son dependencias de desarrollo.

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

    A continuación, permita que la CLI cree la estructura del backend por usted.

    npx hot-updater init
    

    La orden init proporciona el bucket de almacenamiento, crea las tablas necesarias y publica la función periférica update-server que la aplicación llamará. También escribe un archivo .env que contiene las credenciales. Añada ese archivo a .gitignore de inmediato, y documente cómo regenerarlo a partir de las claves service-role de su servicio Supabase, ya que cualquier persona que trabaje con un clon nuevo necesitará crearlo de nuevo.

    Inicialice una vez en el punto de entrada

    HotUpdater.init() debe encontrarse en el ámbito del módulo en index.js y debe ejecutarse una sola vez, antes de cualquier otro uso de la biblioteca. Es lo que indica a hot-updater dónde buscar actualizaciones. Si falta, cada verificación posterior de actualizaciones no cuenta con una URL del servidor y simplemente no hace nada, lo cual es uno de los modos de fallo más confusos para depurar.

    Las importaciones incluyen la biblioteca, react-native-config para valores específicos del entorno, y el servicio de análisis propio de la aplicación.

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

    La configuración en sí misma lee la URL de la función de borde y la estrategia de actualización desde variables de entorno. El manejador onError está diseñado a ser mínimo: se ejecuta durante el arranque, por lo que no debe depender de partes de la aplicación que aún puedan no estar listas. Su única función es registrar que la inicialización falló.

    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),
        });
      },
    });
    

    Observe aquí la separación de responsabilidades. init decide de dónde provienen las actualizaciones. Si la aplicación debe verificar algo o no corresponde a su propia lógica, como una bandera de funcionalidad remota. Mantener estas dos decisiones separadas facilita desactivar las actualizaciones OTA en un entorno sin tocar el código de inicialización.

    Un gancho de actualización silencioso en lugar de wrap()

    La biblioteca incluye un componente de orden superior HotUpdater.wrap() que se encarga automáticamente de verificar, descargar y aplicar las actualizaciones. Si lo único que necesita es “verificar al iniciar, actualizar y listo”, úselo; es el camino más rápido.

    Una aplicación de producción suele necesitar más control: descargas en segundo plano, visualización de su propio modal, limitación de las verificaciones y el encubrimiento de todo el mecanismo detrás de una bandera de funcionalidad. Para ello, funciona mejor un gancho personalizado que accione la API manualmente. La versión a continuación es un núcleo simplificado escrito en TypeScript. Mantiene dos referencias como protectores, una para una verificación en curso y otra para un paquete ya descargado, y expone una referencia de modal, una bandera de actualización forzada y una función de aplicación.

    // 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 };
    };
    

    Varias decisiones en este gancho provienen de problemas reales en entornos de producción:

    • requestTimeout: 20000: el tiempo de espera predeterminado es de 5 segundos. Una función de borde de Supabase en estado frío suele tardar más tiempo, lo que hace que la solicitud se agote y checkForUpdate devuelva null. Ese resultado es indistinguible de “ya está actualizado”, por lo que las actualizaciones nunca llegan. Aumentar el tiempo de espera a 20 segundos absorbe la demora por inicio en frío.
    • Primero preparar, luego aplicar: updateBundle() descarga el nuevo paquete en el disco sin recargar nada. reload() es la única llamada que lo sustituye. Separar estas dos acciones significa que un conductor en medio de una entrega nunca se verá interrumpido por un reinicio repentino; la descarga ocurre en segundo plano y el usuario decide cuándo aplicarla.
  • Un punto de entrada protegido: cada disparador, ya sea al iniciar, al volver al primer plano o al reconectarse, llama a la misma función, y esa función sale temprano cuando busyRef o stagedRef está establecido. Dos verificaciones nunca se ejecutan simultáneamente, y un paquete que ya está preparado nunca se descarga nuevamente.
  • Hay dos detalles importantes que merecen atención. La verificación con retraso de tres segundos funciona como medida de seguridad al iniciar, por si el primer evento de estado active se dispara antes de que el hook se suscriba. Además, la llamada a openModal() asume que el ref apunta a un componente modal personalizado que expone ese método, y no al Modal integrado de React Native; por lo tanto, adapte el tipo de ref a su propio componente.

    Montar el hook y renderizar el prompt

    Llama al hook cercano a la raíz de la aplicación y muestra un único modal que gestione ambos tipos de actualizaciones. Una actualización opcional puede ser descartada; una forzada oculta el botón de cancelar y no se puede cerrar.

    // 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}
      />
    );
    

    Dado que shouldForceUpdate proviene del servidor, tú decides, según cada paquete, si los usuarios pueden posponerla. Reserva las actualizaciones forzadas para correcciones que realmente no pueden esperar.

    Asigna cada compilación nativa a un canal

    El último componente indica a cada binario a qué canal pertenece, por ejemplo development, stage o production. Dado que el valor se compila dentro del binario, el servidor de actualizaciones puede decidir qué secuencia de paquetes recibirá esa versión. En Android, establezca un valor HOT_UPDATER_CHANNEL por cada variante del producto en el manifiesto; en iOS, hágalo por cada destino en Info.plist. Configúrelo una vez por variante o destino y permanecerá sin cambios.

    Hay dos problemas que afectan a casi todos. OTA solo funciona en versiones de lanzamiento, no en versiones de depuración. Además, cada nueva compilación nativa requiere un despliegue para registrarse como paquete base; sin ello, el siguiente despliegue mostrará Skipped (no compatible base bundles).

    Envío de una actualización desde la línea de comandos

    Una vez que todo está conectado, publicar un cambio en JavaScript solo requiere una orden.

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

    Las banderas significan lo siguiente:

    • -p: la plataforma, android o ios.
    • -t: la versión de la aplicación destino con la que es compatible el paquete, que corresponde a tu versionName nativo.
    • -c: el canal al que se publicará.
    • -f: opcional; marca la actualización como forzada para que se aplique de inmediato.

    Guarda tu JavaScript, ejecuta el despliegue, y la próxima vez que alguien abra una versión publicada en ese canal, la actualización ya estará lista.

    Envolver los despliegues en scripts por entorno

    Escribir esa orden para dos plataformas a través de tres canales se vuelve rápidamente tedioso, y es fácil publicarla por error en el canal incorrecto. Un pequeño script de Node controlado por variables de entorno, expuesto a través de scripts npm, resuelve ambos problemas.

    Las entradas de package.json establecen el canal y una bandera opcional de fuerza, y luego llaman al mismo 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"
      }
    }
    

    En scripts/ota-deploy.mjs, el entorno proporciona tanto el canal como la opción de fuerza; el script extrae entonces la versión objetivo de android/app/build.gradle a menos que OTA_VERSION la sobrescriba, y despliega ambas plataformas en secuencia. execSync con stdio: 'inherit' transmite la salida de la CLI a tu terminal y lanza un error si el código de salida no es cero, por lo que un despliegue fallido en Android detiene la ejecución antes de intentar hacerlo en 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' });
    }
    

    El flujo de trabajo diario se reduce entonces a un puñado de comandos.

    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
    

    Obtener la versión desde Gradle en lugar de escribirla a mano elimina toda una categoría de errores en los lanzamientos: no es posible publicar accidentalmente un paquete con una versión de destino obsoleta. Sin embargo, hay que tener en cuenta la suposición que implica. El script utiliza el versionName de Android para ambas plataformas, por lo que solo funciona si la versión de marketing para iOS se mantiene sincronizada. Si ambas pueden desincronizarse, hay que leer la versión de iOS por separado o establecer OTA_VERSION explícitamente. La sintaxis VAR=value incrustada en los scripts de npm también asume un shell POSIX; en Windows se necesitaría una herramienta como cross-env.

    Retroceso y lanzamientos por fases como red de seguridad

    Obtener una solución rápidamente es solo la mitad del trabajo. La otra mitad consiste en limitar el alcance de los efectos negativos cuando un paquete resulta defectuoso, y aquí es donde la consola hot-updater cobra su importancia.

    Retroceso instantáneo

    Cada paquete listado en la consola cuenta con un interruptor Enabled. Si un paquete recién enviado está dañado, desactívalo. Los dispositivos que ya lo habían instalado volverán al último paquete funcional la próxima vez que lo verifiquen, sin necesidad de reconstruirlo, volver a desplegarlo ni esperar a Apple o Google. La CLI ofrece la misma opción de escape a través de hot-updater rollback, que restaura el paquete desplegado más recientemente.

    Esta es la razón principal por la cual se puede confiar en las actualizaciones OTA: corregir un error lleva segundos. Tenga en cuenta que el retroceso solo afecta a los dispositivos cuando estos vuelven a verificarlo, por lo que el mecanismo de verificación en primer plano es tan importante para la recuperación como para la entrega.

    Lanzamientos por fases y cohortes

    En lugar de enviar un paquete a todos los dispositivos al mismo tiempo, puede lanzarlo primero a un pequeño porcentaje, observar los informes de errores y los datos analíticos, y luego aumentar gradualmente la proporción hasta alcanzar el 100%. Las cohortes le permiten dirigirse a un grupo específico, como una cohorte interna de qa, antes de que nadie más vea el cambio. Combinado con la opción de activación, esto convierte un lanzamiento arriesgado en un experimento controlado. Consulte la documentación de hot-updater para conocer las opciones de configuración exactas, ya que pueden cambiar entre versiones.

    Puntos clave

    • Trate OTA como una vía rápida solo para JavaScript; todo lo que involucre código nativo aún necesita una compilación en la tienda, y cada nueva compilación debe registrarse como un paquete base.
  • Llama a HotUpdater.init() exactamente una vez en index.js, y mantén separados los parámetros de “dónde verificar” de los de “si verificar”.
  • Establece un valor para requestTimeout cuando tu servidor de actualizaciones sea una función sin servidor, ya que una verificación vencida se parece exactamente a “no hay actualizaciones disponibles”.
  • Descarga los archivos con updateBundle() y aplícalos con reload() de forma separada, para que los usuarios no se reinicien en medio de una tarea.
  • Escribe scripts para las implementaciones y determina la versión objetivo a partir del proyecto nativo, a fin de evitar errores por canal o versión incorrectos.
  • Planifica el proceso de recuperación antes del primer lanzamiento: ten claro dónde se encuentra el botón de activación y comienza los cambios arriesgados con un despliegue gradual.
  • Lecturas relacionadas