Introduction
Lorsque j'ai eu à déployer ma première application Flutter Web en production, j'ai dû gérer toutes les fonctionnalités habituelles, logiquement liées à un Web Server et surtout:
- le fameux "page non trouvée 404"
- la saisie directe des URL dans la barre de navigation des Browsers (= navigateur)
J'ai effectué beaucoup de recherches sur Internet mais je n'ai jamais trouvé une solution qui résolve mes problèmes.
Cet article explique la solution que j'ai mise en place...
Informations d'arrière-plan
Cet article a été écrit en février 2020 et est basé sur la version 1.14.6 de Flutter, tournant en Channel Beta.
En jetant un oeil à la Flutter Roadmap 2020 , Flutter Web devrait être officiellement mis en production cette année avec pour conséquence que cet article pourrait ne plus être pertinent assez rapidement, car le problème qu'il aborde pourrait être résolu dans les prochains mois.
Afin de résoudre certains problèmes, j'ai également essayé de jouer un peu avec les Service Workers, mais je n'ai trouvé aucune solution de ce côté-là non plus.
Avant de vous donner la solution que j'ai mise en place, je voudrais d'abord revenir sur quelques informations importantes...
Rappel - Flutter Web n'est pas servi par un serveur Web entièrement configurable
"Flutter Web n'est pas servi par un serveur Web entièrement configurable"
Cette déclaration est très importante et très souvent oubliée ...
En effet, lorsque vous exécutez une Application Flutter Web, vous lancez "simplement" un serveur Web de base qui écoute une certaine "adresse_IP:port" et dessert les fichiers situés dans le dossier "web". Très peu de configuration/personnalisation peuvent être réalisées avec cette instance de ce serveur Web.
Des folders web différents
Si vous exécutez Flutter Web en mode "debug", le folder est "/web".
Si vous exécutez Flutter Web en mode "release", le folder est "/build/web"
Lorsque vous exécutez votre application Flutter Web, une fois que le serveur Web de base est activé, la page "index.html" est automatiquement appelée, à partir du dossier "web" correspondant.
La page "index.html" charge automatiquement certains éléments et notamment le fichier "main.dart.js" qui correspond à l'ensemble de l'application. En fait, cela correspond à la transposition Javascript de votre code Dart et de quelques autres bibliothèques.
Donc en d'autres termes ...
Lorsque vous accédez à la page "index.html", vous chargez l'application entière.
Cela signifie qu'une application Flutter Web est une application à page unique (= Single Page Application) et à part pour récupérer des assets (images, fonts...), vous n'aurez normalement plus à interagir avec ce serveur Web une fois la page "index.html" téléchargée et lancée.
Le caractère '#' dans l'URL
Lorsque vous exécutez une application Flutter Web et que vous naviguez d'une page (= Route) à une autre, je suppose que vous avez déjà remarqué le changement au niveau de la barre de navigation URL du navigateur ...
Par exemple, supposons que votre application se compose de 2 pages: la HomePage et une LoginPage. La page d'accueil s'affiche automatiquement au lancement de l'application et dispose d'un bouton pour accéder à la page de connexion.
La barre d'URL du navigateur contiendra:
- http://192.168.1.40:8080/#/ lorsque vous lancez l'application => cela correspond à la HomePage
- http://192.168.1.40:8080/#/LoginPage lorsque la LoginPage est affichée.
Le hashtag spécifie le fragment d'URL, ce qui est couramment utilisé dans les applications de page unique pour la navigation comme alternative aux chemins d'URL.
La chose la plus intéressante à propos du fragment d'URL est que
Les fragments ne sont PAS envoyés dans les requêtes HTTP car les fragments ne sont utilisés que par les navigateurs.
Dans notre cas, dans Flutter Web, ils sont utilisés par le navigateur pour gérer l'historique
(pour plus d'informations sur les fragments, le lien suivant contient des informations intéressantes)
Comment masquer le caractère '#' dans l'URL?
Plusieurs fois, j'ai vu cette question sur Internet et la réponse est très simple.
Comme le caractère '#' correspond généralement à une Page (= Route) dans votre application, vous devez indiquer au navigateur de mettre à jour l'URL tout en continuant à considérer la page dans l'historique de navigation (afin que les boutons 'back' et 'forward' fonctionnent correctement).
Pour réaliser cela, vous avez besoin que la page soit un "StatefulWidget" afin de pouvoir utiliser sa méthode "initState()".
Le code pour y parvenir est le suivant:
import 'dart:html' as html;
import 'package:flutter/material.dart';
class MyPage extends StatefulWidget {
_MyPageState createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
void initState(){
super.initState();
// this is the trick
html.window.history.pushState(null, "MyPage", "/mypage");
}
}
À partir de ce moment, lorsque l'utilisateur sera redirigé vers "MyPage", plutôt que d'afficher "http://192.168.1.40:8080/#/MyPage" dans l'URL, le navigateur affichera "http://192.168.1.40:8080/mypage", ce qui est plus convivial.
Cependant, si vous recopiez cette adresse dans la barre de navigation afin d'accéder directement à cette page, vous serez confronté à la page d'erreur suivante "La page http://192.168.1.40 est introuvable", ce qui correspond à la fameuse erreur HTTP 404.
Alors comment résoudre cela?
Chaque fois que vous accédez à l'application via une URL entrée manuellement, main() est exécuté
Avant d'expliquer la solution, il est également important de noter que lorsque vous entrez une URL "valide" via le navigateur, la page est rechargée et dès lors, la méthode main() est exécutée.
En d'autres termes, si vous entrez manuellement "http://192.168.1.40:8080" ou "http://192.168.1.40:8080/#/page" au niveau de la barre d'URL du navigateur, une demande est envoyée à le serveur Web qui recharge l'application et exécutera la méthode "main()".
Ce n'est pas vrai lorsque, via l'application elle-même, vous passez d'une page à l'autre car le code Javascript n'est exécuté qu'au niveau du navigateur !!
Ma Solution
premier essai... pas concluant...
L'article suivant a déjà tenté de fournir des idées de solutions MAIS la "Best Solution (so far)" décrite dans cet article ne fonctionne pas (ou du moins, je ne suis pas parvenu à la faire fonctionner).
Donc, ma première tentative s'est basée sur la "Second solution", décrite dans ce même article, à savoir:
on mentionne l'extension ".html" lorsque l'on appelle la méthode pushState dans le initState() method comme suit: html.window.history.pushState(null, "MyPage", "/mypage.html");
on crée une page *.html par écran...
Bon, cela fonctionne mais c'est assez contraignant et pas très "propre". Dès lors, j'ai continué mes recherches...
La solution
Alors je me suis dit: "et si j'arrivais à intercepter la requête URL et à la rediriger en utilisant le bon format?".
Donc, quelque chose comme... (je vous dis déjà que cela ne fonctionne pas...)
Malheureusement, comme je vous l'ai déjà dit précédemment, il n'est pas possible de relayer la notion de fragment (avec le caractère #) dans les requêtes HTTP!
Dès lors, il fallait que je trouve autre chose.
Et si je parvenais à faire "croire" à l'application que l'URL était différent?
Par chance, j'ai trouvé le package Dart, appelé Shelf qui est un "Web Server middleware" pour Dart et qui permet de définir des request handlers (c'est-à-dire, des routines qui sont exécutées lors de la réception de requêtes HTTP afin de faire certaines choses).
La solution était alors simple:
- Nous exécutons une instance du Web Server Self, à l'écoute des toutes les requêtes entrantes
- Nous exécutons Flutter Web sur le localhost
- Nous vérifions si une demande fait référence à une page
- Pour toutes ces demandes, nous les redirigeons vers l'URL de base index.html, TOUT EN CONSERVANT l'URL de la demande d'origine, afin qu'elle puisse être interceptée par la méthode main() et afficher la page demandée...
Bien entendu, les requêtes liées aux 'assets' (images, javascript ...) ne doivent pas faire partie de la redirection.
Quelque chose comme ceci:
Ici également, shelf fournit un proxy handler, appelé shelf_proxy, qui permet de relayer les requêtes vers une autre adresse. Exactement ce dont j'avais besoin!
Néanmoins, ce 'proxy handler' ne permet pas d'insérer de logique au niveau de redirections... dommage.
Alors, comme son code source est sous licence BSD, j'ai clôné le code et y ai inséré ma propre logique de re-routage qui consiste simplement à (bien sûr, rien n'empêche de complexifier la logique):
si la requête URL ne fait pas référence à une resource (ex: ".js", ".json", ".png"...) et
que le "path" ne contient qu'une seule partie (ex: "http://192.168.1.40:8080/mypage" et PAS "http://192.168.1.40:8080/assets/package/..."), alors- je redirige la requête vers la page "index.html" de mon server Flutter Web,
sinon, je redirige simplement la requête URL vers mon server Flutter Web SANS mentionner la page "index.html".
Vous allez me dire: "Cela signifie avoir 2 Web Servers!"
Oui, c'est bien cela... 2 Web Servers:
- Le Web Server Proxy (ici, utilisant Shelf), à l'écoute de l'adresse IP réelle
- Celui relatif à l'application Flutter Web, à l'écoute de localhost
Implémentation
1. Créez votre application Flutter Web
Créez votre application Flutter Web comme d'habitude.
2. Modifiez le ficher 'main.dart' (dans /lib)
L'idée est de capturer directement l'information relative au path, fournie par la requête URL.
1
2import 'dart:html' as html;
3import 'package:flutter/material.dart';
4
5void main(){
6 //
7 // Retrieve the path that was sent
8 //
9 final String pathName = html.window.location.pathname;
10
11 //
12 // Tell the Application to take it into consideration
13 //
14 runApp(
15 Application(pathName: html),
16 );
17}
18
19class Application extends StatelessWidget {
20 const Application({
21 this.pathName,
22 });
23
24 final String pathName;
25
26
27 Widget build(BuildContext context){
28 return MaterialApp(
29 onUnknownRoute: (_) => UnknownPage.route(),
30 onGenerateRoute: Routes.onGenerateRoute,
31 initialRoute: pathName,
32 );
33 }
34}
35
36class Routes {
37 static Route<dynamic> onGenerateRoute(RouteSettings settings){
38 switch (settings.name.toLowerCase()){
39 case "/": return HomePage.route();
40 case "/page1": return Page1.route();
41 case "/page2": return Page2.route();
42 default:
43 return UnknownPage.route();
44 }
45 }
46}
47
48class HomePage extends StatefulWidget {
49
50 _HomePageState createState() => _HomePageState();
51
52 //
53 // Static Routing
54 //
55 static Route<dynamic> route()
56 => MaterialPageRoute(
57 builder: (BuildContext context) => HomePage(),
58 );
59
60}
61
62class _HomePageState extends State<HomePage>{
63
64 void initState(){
65 super.initState();
66
67 //
68 // Push this page in the Browser history
69 //
70 html.window.history.pushState(null, "Home", "/");
71 }
72
73
74 Widget build(BuildContext context){
75 return Scaffold(
76 appBar: AppBar(title: Text('Home Page')),
77 body: Column(
78 children: <Widget>[
79 ElevatedButton(
80 child: Text('page1'),
81 onPressed: () => Navigator.of(context).pushNamed('/page1'),
82 ),
83 ElevatedButton(
84 child: Text('page2'),
85 onPressed: () => Navigator.of(context).pushNamed('/page2'),
86 ),
87 //
88 // Intentionally redirect to an Unknown page
89 //
90 ElevatedButton(
91 child: Text('page3'),
92 onPressed: () => Navigator.of(context).pushNamed('/page3'),
93 ),
94 ],
95 ),
96 );
97 }
98}
99
100// Similar code as HomePage, for Page1, Page2 and UnknownPage
101
Explications:
- on capture l'information relative au "path" qui a été envoyé au niveau de la méthode main() (ligne #8) et l'on transmet cette information à Application
- Application considère ce "path" comme "celui au démarrage" => "initialRoute: pathName," (ligne #30)
- la méthode Routes.onGenerateRoute(...) est ensuite appelée et retourne la Route qui correspond au "path" envoyé
- si la Route n'existe pas, il redirige vers la page UnknownPage()
3. Créez le Serveur Proxy
1 -- créez un folder bin, au niveau de la racine de votre projet
2 -- créez un fichier, appelé "proxy_server.dart" dans ce folder /bin
3 -- copiez-y le code suivant:
import 'dart:async';
import 'package:self/self_io.dart' as shelf_io;
import './proxy_handler.dart';
void main() async {
var server;
try {
server = await shelf_io.serve(
proxyHandler("http://localhost:8081"), // redirection to
"localhost", // listening to hostname
8080, // listening to port
);
} catch(e){
print('Proxy error: $e');
}
}
Explications:
La méthode main() initialise simplement une nouvelle instance du web server shelf qui
- écoute "localhost", sur le port 8080
- envoie toutes les requêtes HTTP vers la méthode proxyHandler(), à qui l'on demande de tout envoyer vers "localhost:8081"
4 -- copiez le fichier "proxy_handler.dart" à partir de ce gist, dans le folder "/bin"
Explications:
La seule chose intéressante à expliquer ici se trouve au niveau de la redirection conditionnelle (lignes #44-46, #143-160)
...
if (_needsRedirection(requestUrl.path)){
requestUrl = Uri.parse(url + "/index.html");
}
...
qui simplement demande à la routine de rediriger la requête HTTP vers page page "/index.html" SANS modifier le contenu de Request URL qui est transmis par la requête HTTP (donc "http://localhost:8080/page1" par exemple). Ce sera la partie "path" de la requête ("/page1") qui sera interceptée par la méthode main() de l'application Flutter Web.
Comment lancer/exécuter cette solution?
1 -- Démarrez l'application Flutter Web, en lui demandant d'écouter "localhost" sur le port "8081", par exemple:
(debug) flutter run -d web --web-port 8081 --web-hostname localhost
(release) flutter run -d web --release --web-port 8081 --web-hostname localhost
2 -- Démarrez le Proxy Web Server
dart bin/proxy_server.dart
Conclusions
Comme j'avais besoin de publier une application Flutter Web en production, je devais trouver une solution capable de gérer:
- les exceptions d'URL (telles que "Page non trouvée - erreur 404");
- les URL conviviales (sans le caractère #)
La solution que j'ai mise en place (le sujet de cet article), fonctionne mais cela ne peut être considéré que comme une solution de contournement (= workaround).
Je suppose qu'il devrait exister d'autres solutions un peu plus "officielles" mais je n'en ai trouvé aucune autre à ce jour.
J'espère vraiment que l'équipe Flutter pourra bientôt résoudre ce problème, afin qu'une solution "propre" existe ou la documente au cas où elle serait déjà disponible.
Restez à l'écoute pour de nouveaux articles très bientôt et, en attendant laissez-moi vous souhaiter un bon codage!