Super App usando Re.Pack
Olá Mundo,
Se você caiu nesse artigo provavelmente você está montando ou tem curiosidade de como pode funcionar um Super App.
Quando falamos de um Super App, do ponto de vista de produto, estamos falando de m aplicativo que oferece uma ampla gama de serviços em uma única plataforma, com o objetivo de fornecer uma solução conveniente e completa para os usuários. Saiba o porque o seu negócio pode precisar de um Super App aqui.
Do ponto de vista técnico temos alguns desafios na construção dessa tecnologia:
- Navegação entre módulos;
- Permissões entre os módulos (miniapps);
- Tamanho do aplicativo;
- Separação de código para times diferentes;
- Deploys contínuos por times; Um livro interessante que fala mais sobre esses desafios é o Building Mobile Apps at Scale
Alguns desses desafios podem ser atacados com o Re.Pack, um kit de ferramentas feito especificamente para aplicativos React Native que utiliza Webpack nos bastidores. Ele serve como uma alternativa ao Metro, permitindo que recursos como Code Splitting sejam possíveis dentro do ecossistema React Native.
O Re.Pack foi criado pela CallStack uma consultoria de tecnologia focada no ecossistema React Native. Eles lançaram um treinamento para construção de super apps, trago nesse artigo os meus aprendizados nesse treinamento.
Super App Exemplo
Vamos começar com um repositório de exemplo com dois aplicativos dentro de um monorepo, ou seja, HostApp e MiniApp.
- O HostApp é um aplicativo simples com HomeScreen e DetailScreen, que você pode acessar por meio de um botão na HomeScreen.
- MiniApp é semelhante, mas possui um botão que navega até a GalleryScreen com uma lista de fotos.
git clone --branch initial https://github.com/kibolho/super-app-example.git
Em ambos packages/host-app e packages/mini-app instale Re.Pack com suas dependências:
yarn add -D webpack terser-webpack-plugin babel-loader @callstack/repack
Agora ao invés de rodar:
react-native start
Para iniciar o Bundler Metro, vamos rodar:
"start": "react-native webpack-start",
Para iniciar o Webpack.
Vinculando o MiniApp ao HostApp
MiniApp
No arquivo webpack.config.mjs do MiniApp, vamos adicionar o plugin do Repack de Module Federation:
Para identificar se o MiniApp está rodando standalone, adicionamos essa constante no topo do arquivo:
const STANDALONE = Boolean(process.env.STANDALONE);
Dentro do array de plugins:
new Repack.plugins.ModuleFederationPlugin({
name: 'MiniApp',
exposes: {
'./MiniAppNavigator': './src/navigation/MainNavigator',
},
shared: {
react: {
singleton: true,
eager: STANDALONE,
requiredVersion: '18.2.0',
},
'react-native': {
singleton: true,
eager: STANDALONE,
requiredVersion: '0.72.3',
},
'@react-navigation/native': {
singleton: true,
eager: STANDALONE,
requiredVersion: '6.1.6',
},
'@react-navigation/native-stack': {
singleton: true,
eager: STANDALONE,
requiredVersion: '6.9.12',
},
'react-native-safe-area-context': {
singleton: true,
eager: STANDALONE,
requiredVersion: '4.5.0',
},
'react-native-screens': {
singleton: true,
eager: STANDALONE,
requiredVersion: '3.20.0',
},
},
})
- Exposes: Indica os módulos que serão expostos e o nome desses módulos.
- Shared: indica quais os pacotes serão compartilhados;
- Singleton: indica quais serão instanciados apenas uma vez;
- Eager: indica aqueles que eles serão inicializados pelo módulo, no caso do miniapp só serão inicializados caso esteja rodando de forma individual.
Atualizamos também os script para iniciar o servidor de desenvolvimento em uma porta diferente da default (8081), visto que a default será usada pelo HostApp.
"start": "react-native webpack-start --port 9000",
"start:standalone": "STANDALONE=1 react-native webpack-start",
O navigator exposto pelo miniapp só irá mostrar o um botão de voltar caso não esteja rodando standalone:
import {
NativeStackNavigationProp,
createNativeStackNavigator,
} from '@react-navigation/native-stack';
import React from 'react';
import {ImageRequireSource, StyleSheet} from 'react-native';
import {
HeaderBackButton,
HeaderBackButtonProps,
} from '@react-navigation/elements';
import GalleryDetailScreen from '../screens/GalleryDetailScreen';
import GalleryScreen from '../screens/GalleryScreen';
import HomeScreen from '../screens/HomeScreen';
const STANDALONE = Boolean(process.env.STANDALONE);
export type MainStackParamList = {
Home: undefined;
Gallery: undefined;
GalleryDetail: {
imageSource?: ImageRequireSource;
};
};
export type MainStackNavigationProp =
NativeStackNavigationProp<MainStackParamList>;
const Main = createNativeStackNavigator<MainStackParamList>();
const BackButton: React.FC<HeaderBackButtonProps & {navigation: any}> = ({
navigation,
...props
}) => (
<HeaderBackButton
{...props}
onPress={() => navigation.goBack()}
labelVisible={false}
/>
);
const MainNavigator = () => {
const NavOptions = {
headerTitle: 'MiniApp',
headerBackTitleVisible: false,
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
headerTintColor: 'rgba(255,255,255,1)',
};
return (
<Main.Navigator screenOptions={NavOptions}>
<Main.Screen
name="Home"
component={HomeScreen}
options={
STANDALONE
? undefined
: ({navigation}) => ({
...NavOptions,
headerLeft: props => BackButton({navigation, ...props}),
})
}
/>
<Main.Screen name="Gallery" component={GalleryScreen} />
<Main.Screen name="GalleryDetail" component={GalleryDetailScreen} />
</Main.Navigator>
);
};
const styles = StyleSheet.create({
header: {
backgroundColor: 'rgba(79, 55, 139, 1)',
},
headerTitle: {
color: 'rgba(255,255,255,1)',
},
});
export default MainNavigator;
HostApp
Agora vamos preparar o HostApp, no arquivo webpack.config.mjs adicione o plugin de module federation ao array de plugins:
new Repack.plugins.ModuleFederationPlugin({
name: 'HostApp',
shared: {
react: {
singleton: true,
eager: true,
requiredVersion: '18.2.0',
},
'react-native': {
singleton: true,
eager: true,
requiredVersion: '0.72.3',
},
'@react-navigation/native': {
singleton: true,
eager: true,
requiredVersion: '6.1.6',
},
'@react-navigation/native-stack': {
singleton: true,
eager: true,
requiredVersion: '6.9.12',
},
'react-native-safe-area-context': {
singleton: true,
eager: true,
requiredVersion: '4.5.0',
},
'react-native-screens': {
singleton: true,
eager: true,
requiredVersion: '3.20.0',
},
},
}),
],
Para consumir o MiniApp dentro do HostApp, no arquivo index.js do HostApp vamos adicionar o script do MiniApp:
ScriptManager.shared.addResolver(async (scriptId, caller) => {
const resolveURL = Federated.createURLResolver({
containers: {
MiniApp: 'http://localhost:9000/[name][ext]',
},
});
const url = resolveURL(scriptId, caller);
if (url) {
return {
url,
query: {
platform: Platform.OS,
},
};
}
});
Agora podemos consumir os módulos expostos pelo MiniApp, nesse caso podemos criar uma screen, usando o MiniAppNavigator do MiniApp:
import React from 'react';
import {Federated} from '@callstack/repack/client';
import {ActivityIndicator, StyleSheet, View} from 'react-native';
const MiniAppNavigator = React.lazy(() =>
Federated.importModule('MiniApp', './MiniAppNavigator'),
);
const FallbackComponent = () => (
<View style={styles.container}>
<ActivityIndicator color="gba(56, 30, 114, 1)" size="large" />
</View>
);
const MiniAppScreen = () => (
<React.Suspense fallback={<FallbackComponent />}>
<MiniAppNavigator />
</React.Suspense>
);
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
export default MiniAppScreen;
E adicionamos essa screen ao MainNavigator do HostApp:
import React from 'react';
import {StyleSheet, ImageRequireSource} from 'react-native';
import {
createNativeStackNavigator,
NativeStackNavigationProp,
} from '@react-navigation/native-stack';
import HomeScreen from '../screens/HomeScreen';
import DetailScreen from '../screens/DetailScreen';
import MiniAppScreen from '../screens/MiniAppScreen';
export type MainStackParamList = {
Home: undefined;
Detail: undefined;
MiniApp: undefined;
};
export type MainStackNavigationProp =
NativeStackNavigationProp<MainStackParamList>;
const Main = createNativeStackNavigator<MainStackParamList>();
const MainNavigator = () => {
return (
<Main.Navigator
screenOptions={{
headerTitle: 'HostApp',
headerBackTitleVisible: false,
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
headerTintColor: 'rgba(255,255,255,1)',
}}>
<Main.Screen name="Home" component={HomeScreen} />
<Main.Screen name="Detail" component={DetailScreen} />
<Main.Screen
name="MiniApp"
component={MiniAppScreen}
options={{headerShown: false}}
/>
</Main.Navigator>
);
};
const styles = StyleSheet.create({
header: {
backgroundColor: 'rgba(56, 30, 114, 1)',
},
headerTitle: {
color: 'rgba(255,255,255,1)',
},
});
export default MainNavigator;
Preparando para produção
Agora precisamos preparar nosso aplicativo para produção, para isso vamos assinar nossos bundles com uma chave privada para podermos validar com a chave publica a autenticidade dos bundles.
Primeiro gere as chaves pública e privada com o comando:
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key && openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
Lembre-se de ignorar via git as chaves geradas. Adicione ao arquivo .gitignore:
*.key*
No arquivo webpack.config.mjs do MiniApp adicione ao array de plugins:
new Repack.plugins.CodeSigningPlugin({
enabled: mode === 'production',
privateKeyPath: path.join('..', '..', 'jwtRS256.key'),
outputPath: path.join('build', 'outputs', platform, 'remotes'),
}),
Esse plugin do Repack vai assinar o bundle gerado no caminho indicado.
Você também pode adicionar o plugin de conversão para Bytecode usando a engine Hermes:
new Repack.plugins.ChunksToHermesBytecodePlugin({
enabled: mode === 'production' && !devServer,
test: /\.(js)?bundle$/,
exclude: /index.bundle$/,
}),
E agora para gerar o bundle IOS e Android podemos adicionar os seguintes scripts ao package.json.
"bundle": "yarn bundle:android && yarn bundle:ios",
"bundle:android": "react-native webpack-bundle --dev false --platform android --entry-file index.js",
"bundle:ios": "react-native webpack-bundle --dev false --platform ios --entry-file index.js",
"server": "python3 -m http.server -d build/outputs 9000"
Como você pode perceber adicionamos também um script server, que vai servir os arquivos bundle via um serviço http na porta 9000 para serem consumidos pelo HostApp.
Agora precisamos ler o bundle do MiniApp no HostApp, como ele está assinado, precisamos da chave pública para validá-lo. Para isso, adicionamos a chave pública que foi gerada ao arquivo, packages/host-app/android/app/src/main/res/values/strings.xml:
<resources>
<string name="app_name">HostApp</string>
<string name="RepackPublicKey">
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwer7+uAwnmQDbXEbOJn0
zTYbfVgZafppn9WYjbtCJlfhFzEaSkhAFzcW942MjwxhOMG8H+z25k7XgB5ddtmx
BRuYpwuOR+lCAbbmr1NWWoY8TSFH+Jwj8A2F5R33h8ezz/fFdM8SQ3hnxB3oWsDK
fVZffI3W14PjJa6h0SqV5n0ltLsDQoAz1IaOWp+Asu99wF5R2/+z13uzC+VY6Ni3
qG7ubpw1iiNnMwJPy0y2m9yvmFRtC1v25ztp4jWmTA/CKNtZ/03KiL8BNNltWgIo
l6q8iYfnwleM8e42hXZdojzMJTfW7/U1dHtomIaQFaljQpEKUNuz/4oi9B3L4/pk
GKWWrBzWHaAxtrblzsiWOmRPI//iyhyTF0qGO58Fig3hhul1cqp2gpCeZyb6aYyk
H6TH4nN9ltNlMX3OXhE/5TFEoqiAgiBXtyr/+bFOEdxPGP9Oq2y3XHob4GS5XSKJ
vX4tdAvDVy8PBv1vaWPLJ/av5QspFRV5/3nuH4RXVMg/TkAobSydyEqwylzpXQUw
Wdc2OH2JbOTmPTgwH9IVaVfcIUi3jF4dP4uKOsvk4qi5wb7a2PzojLPXGKXw7ayi
/AkcAkmsp+IkWokCBxhsCDAZxqfIqFP7qOK0+Zwj2VexC48+gWKmb9YEUGLGVSdl
aMBhUYcbKWcUMwXWoymDu/ECAwEAAQ==
-----END PUBLIC KEY-----
</string>
</resources>
E ao arquivo packages/host-app/android/app/src/main/res/values/strings.xml:
<key>RepackPublicKey</key>
<string>
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwer7+uAwnmQDbXEbOJn0
zTYbfVgZafppn9WYjbtCJlfhFzEaSkhAFzcW942MjwxhOMG8H+z25k7XgB5ddtmx
BRuYpwuOR+lCAbbmr1NWWoY8TSFH+Jwj8A2F5R33h8ezz/fFdM8SQ3hnxB3oWsDK
fVZffI3W14PjJa6h0SqV5n0ltLsDQoAz1IaOWp+Asu99wF5R2/+z13uzC+VY6Ni3
qG7ubpw1iiNnMwJPy0y2m9yvmFRtC1v25ztp4jWmTA/CKNtZ/03KiL8BNNltWgIo
l6q8iYfnwleM8e42hXZdojzMJTfW7/U1dHtomIaQFaljQpEKUNuz/4oi9B3L4/pk
GKWWrBzWHaAxtrblzsiWOmRPI//iyhyTF0qGO58Fig3hhul1cqp2gpCeZyb6aYyk
H6TH4nN9ltNlMX3OXhE/5TFEoqiAgiBXtyr/+bFOEdxPGP9Oq2y3XHob4GS5XSKJ
vX4tdAvDVy8PBv1vaWPLJ/av5QspFRV5/3nuH4RXVMg/TkAobSydyEqwylzpXQUw
Wdc2OH2JbOTmPTgwH9IVaVfcIUi3jF4dP4uKOsvk4qi5wb7a2PzojLPXGKXw7ayi
/AkcAkmsp+IkWokCBxhsCDAZxqfIqFP7qOK0+Zwj2VexC48+gWKmb9YEUGLGVSdl
aMBhUYcbKWcUMwXWoymDu/ECAwEAAQ==
-----END PUBLIC KEY-----
</string>
Agora teremos uma nova URL aonde buscar nosso bundle de produção, por isso temos que editar o arquivo index.js do nosso HostApp:
ScriptManager.shared.addResolver(async (scriptId, caller) => {
let containers;
if (__DEV__) {
containers = {
MiniApp: 'http://localhost:9000/[name][ext]',
};
} else {
containers = {
MiniApp: `http://localhost:9000/${Platform.OS}/remotes/[name][ext]`,
};
}
const resolveURL = Federated.createURLResolver({containers});
const url = resolveURL(scriptId, caller);
if (url) {
return {
url,
query: {
platform: Platform.OS,
},
verifyScriptSignature: __DEV__ ? 'off' : 'strict',
};
}
});
Agora podemos rodar nosso HostApp em modo release que o bundle executado sera o servido estaticamente pelo servidor http:
react-native run-ios --mode Release
Aqui está a versão final do Super App na branch final. Qualquer dúvida, sugestão não hesite em contactar-me.
Com o Re.Pack é possível montar a seguinte arquitetura:
- Miniapps podem ficar em repositórios separados, sendo possível rodá-los individualmente.
- O Bundle dos miniapps podem ser hospedados em buckets como o S3;
- Podemos ter um servidor de catálogo para guardar as versões e urls dos bundles;
O repositório com um SuperApp mais completo, com mais miniapps, em repositórios separados pode ser encontrado aqui.