Apps construidas por separado instalando paquetes versionados (un contrato tipado y una pantalla compartida) desde un mismo registro

El paquete de contratos: una frontera versionada entre remotes federados en React Native

El post 4 terminó con una promesa: las dos pestañas querrían abrir la misma pantalla de detalle de Pokémon, y apps construidas por separado tendrían que ponerse de acuerdo en qué pasarle.

Este post construye eso, y hacen falta tres cambios que se necesitan entre sí. Cada remote deja de exponer una pantalla suelta y expone un stack de navegación entero, así que empujar un detalle es asunto de la pestaña que lo empuja. La propia pantalla de detalle se publica como un paquete versionado que las dos pestañas instalan, no como otra unidad de despliegue, por una razón que merece explicarse. Y el acuerdo sobre lo que cruza hacia esa pantalla se convierte también en un paquete: un contrato con número de versión, que es donde vive la lección de verdad de este post. El acto final rompe el acuerdo a propósito, tres peldaños arriba en una escalera de versiones, y termina en un agujero que ningún compilador del repo puede ver.

Continúa desde tu propio código del post 4 si lo has ido construyendo. Si no:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-04-host-shell

Cada pestaña gana un stack

En el post 4 cada remote entregaba al host una pantalla suelta y el host las ordenaba en pestañas. Una pantalla de detalle cambia lo que significa “dentro de una pestaña”: tocar una fila debe empujar una pantalla nueva mientras la barra de pestañas se queda donde está, y quien es dueño de ese empujón es dueño de la navegación dentro de la pestaña. Eso es asunto del remote, no del shell. Así que cada remote expone ahora un stack.

apps/list/src/ListStack.tsx:

import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import PokemonDetailScreen from '@pokedex/detail';
import type { ListParamList } from './routes';
import PokedexScreen from './PokedexScreen';

const Stack = createNativeStackNavigator<ListParamList>();

export default function ListStack() {
  return (
    <Stack.Navigator screenOptions={{ headerShown: false }}>
      <Stack.Screen
        name="PokedexList"
        component={PokedexScreen}
        options={{ title: 'Pokédex' }}
      />
      <Stack.Screen
        name="PokemonDetail"
        component={PokemonDetailScreen}
        options={{ headerShown: true, title: '' }}
      />
    </Stack.Navigator>
  );
}

Ese import PokemonDetailScreen from '@pokedex/detail' es el segundo cambio del post, y volveremos a él; primero, el cableado a su alrededor. La superficie pública del remote cambió: expone ./ListStack donde antes exponía ./PokedexScreen, así que su config de rspack renombra la entrada en exposes, y los dos imports lazy del host pasan a ser listApp/ListStack y partyApp/PartyStack. La app party lo replica todo con su propio PartyStack.

El mapa de compartidos evoluciona también, en las dos direcciones de la regla del post 4. Los remotes ahora importan @react-navigation/native, @react-navigation/native-stack y react-native-screens, así que los tres se unen a los mapas de singletons compartidos de cada lado. El mapa registra lo que importa más de una parte, y tres paquetes más acaban de cruzar esa línea. Dos de las entradas nuevas declaran version a mano:

'@react-navigation/native': {
  singleton: true,
  version: navPkg.version,
  requiredVersion: pkg.dependencies['@react-navigation/native'],
},

Rspack normalmente lee la versión del paquete que está compartiendo, pero no puede con un paquete resuelto a través de un mapa exports, y React Navigation tiene uno. Sin el version explícito el bundler omite el provide en silencio: nada aterriza en el share scope, y la app muere al arrancar con RUNTIME-006 (eager) o empaqueta un duplicado sin decir nada (sin eager). Los paquetes de navegación lo necesitan; react-native-screens no. Mirar si hay un campo exports en el package.json de un paquete te dice qué tratamiento le toca.

Ponlo en marcha y el cambio de producto se ve: toca una fila, el detalle se empuja dentro de la pestaña, y la barra de pestañas se queda en pantalla.

La pestaña Pokédex en iOS: tocar una fila empuja la pantalla de detalle del Pokémon dentro de la pestaña mientras la barra inferior de pestañas sigue visible

¿Dónde vive la pantalla compartida?

Los dos stacks montan la misma pantalla de detalle. En el producto de esta serie es una pantalla; en una app real es el tipo de cosa que dos equipos comparten de verdad. ¿Dónde va?

La respuesta federada se insinúa sola: un tercer remote, un detailApp en su propio puerto, declarado en los mapas remotes de los dos consumidores. Funciona mecánicamente (Module Federation resuelve remotes anidados sin quejarse) y mantiene la pantalla actualizable sin tocar a ningún consumidor. También es el corte equivocado. Una frontera de federación es una unidad de despliegue, con su servidor de desarrollo, su pipeline de despliegue y sus propios modos de fallo. Los equipos cortan esas fronteras por dominios: un equipo, un dominio, un remote, varias pantallas dentro. Una sola pantalla nunca se gana ese coste, y un código que reparte una frontera por pantalla va camino de desplegar una app por pantalla.

Una pantalla que comparten dos dominios es un componente, y los componentes compartidos llevan décadas con su mecanismo de entrega: un paquete. Así que la pantalla de detalle se publica como @pokedex/detail, subida a un registro e instalada por las dos apps de pestaña, y cada una la monta en su propio stack donde le parece. Las pestañas siguen siendo las unidades de despliegue. La pantalla es una dependencia. Esa regla se defiende a fondo dentro de dos posts: una frontera es el dominio de un equipo, no una pantalla.

packages/detail/src/PokemonDetailScreen.tsx, las partes que importan:

import type { DetailParams } from '@pokedex/contracts';

const POKEMON: Record<number, { name: string; types: string[] }> = {
  1: { name: 'Bulbasaur', types: ['Grass', 'Poison'] },
  4: { name: 'Charmander', types: ['Fire'] },
  7: { name: 'Squirtle', types: ['Water'] },
  25: { name: 'Pikachu', types: ['Electric'] },
  133: { name: 'Eevee', types: ['Normal'] },
};

export interface PokemonDetailScreenProps {
  route: { params: DetailParams };
}

export default function PokemonDetailScreen({ route }: PokemonDetailScreenProps) {
  const { id } = route.params;
  const pokemon = POKEMON[id];
  // renderiza el número de la dex, el nombre, el sprite y los chips de tipo
}

Hay dos cosas deliberadas aquí. La pantalla lleva su propia copia de los datos de Pokémon: la app list tiene otra, y las dos ya se han desviado, porque la lista necesita nombres y esta pantalla necesita además los tipos. Dos copias de los mismos hechos huelen mal, y el olor está plantado a propósito: los datos en vivo borran las dos en el próximo post. Y las props se tipan estructuralmente ({ route: { params: DetailParams } }) en vez de importar los tipos de React Navigation. La pantalla la montan dos stacks que no ha visto nunca, así que declara la forma que necesita y queda libre de una dependencia de navegación. Su import de DetailParams es el paquete de contratos, que este post tiene que explicar ahora.

El acuerdo del que ninguna app puede ser dueña

Mira lo que tiene que cuadrar. La app list empuja PokemonDetail con { id }. La app party empujará la misma ruta cuando tenga miembros que tocar. La pantalla lee route.params.id. Tres códigos tocan una forma, y ninguno puede definirla para los demás: una definición en la app list es invisible para el paquete de la pantalla, y copiar el tipo a mano en cada código es deriva con cuenta atrás.

El mismo problema existe un nivel más arriba, donde el host consume los remotes. Las declaraciones ambient del host describen módulos sin archivo al que apuntar:

declare module 'listApp/ListStack' {
  import type { ListStackModule } from '@pokedex/contracts';
  const ListStack: ListStackModule;
  export default ListStack;
}

Una declaración ambient es una promesa escrita a mano. TypeScript se la cree porque no hay nada contra lo que comprobarla: el módulo que describe se resuelve en runtime, desde un servidor, mucho después de que el compilador termine. Apuntar la declaración a un tipo de un paquete instalado no hace la promesa comprobable; hace la promesa compartida, para que el host y el remote al menos describan la superficie desde una definición en vez de dos suposiciones. Lo que sigue sin poder atrapar, dicho con honestidad: si el remote renombra su expose, la declaración se queda obsoleta y el compilador sigue en verde. La comprobación de esa frontera no existe en build. Guarda esa frase; el final de este post está construido sobre ella.

Así que los acuerdos viven en @pokedex/contracts, un paquete que tiene tipos y nada más:

// packages/contracts/src/params.ts
export interface DetailParams {
  id: number;
}

export type DetailParamList = {
  PokemonDetail: DetailParams;
};

Cada stack incrusta el fragmento en su propia lista de params (type ListParamList = DetailParamList & { PokedexList: undefined }), el paquete de la pantalla lee DetailParams, y el host tipa sus declaraciones de módulo desde el mismo sitio. Todo lo del paquete se borra en build. Nada suyo llega a un bundle; existe para que cada tsc del repo compruebe contra una sola definición.

Publica los dos a un registro

Un paquete necesita un registro, y las rutas file: no valen: una ruta es una ubicación en una máquina, y el sentido de los dos paquetes es que apps construidas por separado (en una organización real, repos con dueños distintos) instalen el mismo artefacto por nombre y versión. Verdaccio es un registro npm en un solo proceso, suficiente para que todo el flujo sea real:

npx verdaccio                                    # :4873, se queda arriba
npm adduser --registry http://localhost:4873     # cualquier usuario, contraseña y correo

Un .npmrc de una línea en la raíz del repo apunta el scope hacia él (@pokedex:registry=http://localhost:4873/) y los dos paquetes se publican:

( cd packages/contracts && npm install && npm run build && npm publish )
( cd packages/detail && npm install && npm run build && npm publish )
+ @pokedex/contracts@1.0.0
+ @pokedex/detail@1.0.0

El paquete de detalle declara lo que necesita como peers en vez de empaquetarlo:

"peerDependencies": {
  "@pokedex/contracts": ">=1",
  "react": "*",
  "react-native": "*",
  "react-native-safe-area-context": ">=5"
}

Un rango peer es una afirmación de compatibilidad, y >=1 es una laxa: dice que esta pantalla funciona con cualquier contrato desde 1.0.0 en adelante. Los rangos laxos dan a los consumidores libertad para actualizar a su ritmo. Lo que cuestan es precisión, y el final de este post enseña el precio.

Las tres apps instalan los dos paquetes. Una instalación real, por versión, desde el registro:

npm install @pokedex/contracts@^1.0.0 @pokedex/detail@^1.0.0

Cada app desempaqueta su propia copia en node_modules, con la versión que pidió. Cuatro copias del contrato por el código suena otra vez al problema de la deriva, hasta que notas la diferencia: estas copias llevan número de versión, y los números de versión son de lo que va el resto del post.

Ahora rómpelo: la escalera de versiones

El montaje funciona. Las dos pestañas montan la pantalla instalada, los empujones comprueban tipos contra el contrato instalado, y el simulador se porta bien. La pregunta interesante es qué pasa cuando los paquetes se mueven y las apps no, porque en una organización real no lo harán, no a la vez. Tres peldaños.

1.1.0, una adición

La app party acabará pasando un id de instancia de hueco cuando un Pokémon tocado venga de un hueco del equipo. Campo opcional, cambio aditivo:

export interface DetailParams {
  id: number;
  uid?: string;
}

Sube a 1.1.0, compila, publica. Después, en la app list, la parte que casi todas las explicaciones se saltan:

npm install
up to date, audited 1188 packages in 926ms

No pasa nada. El lockfile fija 1.0.0, y npm install respeta el lockfile. El caret del package.json dice lo que esta app aceptaría; el lockfile decide lo que recibe. Tomar el minor es un acto deliberado:

npm update @pokedex/contracts
changed 1 package, and audited 1188 packages in 868ms

Ahora npm ls @pokedex/contracts enseña la nueva resolución, incluida la pantalla, que la acepta a través de su peer laxo:

+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@1.0.0
  `-- @pokedex/contracts@1.1.0 deduped

tsc pasa sin cambiar una línea de código en ningún sitio: un consumidor construido contra 1.0.0 nunca pasa uid, y sigue satisfaciendo el tipo. Eso es lo que hace seguro desplegar un cambio aditivo de forma desigual.

2.0.0, un campo que tiene que estar

El siguiente cambio es de los que rompen. La pantalla de detalle quiere enseñar desde dónde se abrió, así que el param pasa a ser obligatorio, y la pantalla aprende a renderizarlo:

export interface DetailParams {
  id: number;
  uid?: string;
  source: 'pokedex' | 'party';
}

Un campo obligatorio invalida la forma antigua, así que el contrato sube a 2.0.0. El paquete de la pantalla lo sigue hasta ahí: @pokedex/detail@2.0.0, construido contra el contrato nuevo, renderizando una pequeña línea de origen desde route.params.source. De vuelta en la app list, con los dos en el registro:

npm install
up to date, audited 1188 packages in 886ms

Se queda en 1.1.0. El caret rechaza el major sin que se lo pidan, que es la única pieza de todo este montaje que funciona por defecto.

El punto ciego

Tres equipos publican a tres velocidades. El equipo de party adopta los dos 2.0.0 y compila limpio: todavía no empuja nada, así que el nuevo requisito no le cuesta nada. El equipo de list tiene una release fuera y se queda en el contrato 1.1.0. Pero el paquete de la pantalla es una dependencia más, y las dependencias se actualizan: alguien del equipo de list toma la pantalla nueva sin tomar el contrato nuevo.

npm install @pokedex/detail@^2.0.0
changed 1 package, and audited 1188 packages in 1s

Sin avisos. El rango peer de la pantalla dice >=1, la app list tiene 1.1.0, y 1.1.0 >= 1. npm queda satisfecho:

+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@2.0.0
  `-- @pokedex/contracts@1.1.0 deduped

Esta es la parte en la que merece la pena frenar. La app list empaqueta ahora una pantalla cuyo código de runtime lee route.params.source. Pregunta qué comprobó su compilador, y la respuesta es: las declaraciones de tipos publicadas de la pantalla dicen que las props toman DetailParams, importado de @pokedex/contracts y resuelto a la copia que el consumidor instaló. La app list lo resolvió a 1.1.0, donde source no existe. Así que el empujón de { id } de la app list comprueba tipos perfectamente contra la misma pantalla que está a punto de leer un campo que nunca le enviaron. Cada tsc del código está en verde, y cada uno está comprobando una frase distinta de la que el runtime va a pronunciar.

En runtime, la pestaña list empuja a Bulbasaur:

La pantalla de detalle del Pokémon mostrando 'Opened from the' sin nada después, porque la app list tiene una versión más antigua del contrato y nunca pasó el campo que la pantalla más nueva renderiza

“Opened from the ”, y después nada. Sin crash, sin red box, sin un aviso en ninguna terminal. Una frase con un agujero, enviada a un usuario.

El contrato no falló. Hizo lo que un contrato de tiempo de compilación puede hacer, que es sujetar cada app a la versión que esa app instaló. El peer laxo tampoco falló; hizo exactamente lo que dice >=1. El agujero está entre los dos: un tipo es una promesa de tiempo de compilación, y ya no existe cuando un valor cruza de verdad. Comprobar el valor que llega es otro trabajo, de otras herramientas, en otro momento.

Lo que has construido, y lo que viene

La navegación bajó a los remotes. La pantalla compartida se publica como paquete versionado, porque una frontera de federación es el dominio de un equipo y una pantalla es un componente. Los acuerdos (params y formas de módulo) viven en un paquete de contratos que cada lado instala, y semver gobierna cómo se desvían los tres códigos: los minors se despliegan de forma desigual y sin daño, los majors esperan consentimiento, y el único fallo que se coló lo hizo en silencio, en el hueco entre un rango peer laxo y un tipo borrado.

El código terminado de este post es el tag post-05-contracts:

git checkout post-05-contracts

Lo próximo en la serie: datos reales. Las listas de Pokémon hardcodeadas desaparecen, las dos copias, y cada pantalla lee de un store alimentado por PokéAPI. Que es donde el agujero de esa frase recibe una respuesta de verdad, porque un tipo comprobado en build no sirve de nada frente a un valor que aparece en runtime.

Fuentes

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil