Comment gérer l’erreur “Page non trouvée 404”, les saisies manuelles directes d’URL et éviter le caractère # (hash ou hashtag) dans l’URL?

Difficulté: Avancé

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 {
    @override
    _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
    @override
    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…)

Proxy Server

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:

Proxy Server 2

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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import 'dart:html' as html;
import 'package:flutter/material.dart';

void main(){
    //
    // Récupérer le path qui a été envoyé
    //
    final String pathName = html.window.location.pathname;

    //
    // Dire à l'application de prendre cette information en considération
    //
    runApp(
        Application(pathName: html),
    );
}

class Application extends StatelessWidget {
    const Application({
        this.pathName,
    });

    final String pathName;

    @override
    Widget build(BuildContext context){
        return MaterialApp(
            onUnknownRoute: (_) => UnknownPage.route(),
            onGenerateRoute: Routes.onGenerateRoute,
            initialRoute: pathName,
        );
    }
}

class Routes {
    static Route<dynamic> onGenerateRoute(RouteSettings settings){
        switch (settings.name.toLowerCase()){
            case "/": return HomePage.route();
            case "/page1": return Page1.route();
            case "/page2": return Page2.route();
            default:
                return UnknownPage.route();
        }
    }
}

class HomePage extends StatefulWidget {
    @override
    _HomePageState createState() => _HomePageState();

    //
    // Static Routing
    //
    static Route<dynamic> route() 
        => MaterialPageRoute(
                builder: (BuildContext context) => HomePage(),
            );

}

class _HomePageState extends State<HomePage>{
    @override
    void initState(){
        super.initState();

        //
        // Push this page in the Browser history
        //
        html.window.history.pushState(null, "Home", "/");
    }

    @override
    Widget build(BuildContext context){
        return Scaffold(
            appBar: AppBar(title: Text('Home Page')),
            body: Column(
                children: <Widget>[
                    RaisedButton(
                        child: Text('page1'),
                        onPressed: () => Navigator.of(context).pushNamed('/page1'),
                    ),
                    RaisedButton(
                        child: Text('page2'),
                        onPressed: () => Navigator.of(context).pushNamed('/page2'),
                    ),
                    //
                    // Intentionally redirect to an Unknown page
                    //
                    RaisedButton(
                        child: Text('page3'),
                        onPressed: () => Navigator.of(context).pushNamed('/page3'),
                    ),
                ],
            ),
        );
    }
}

// Code similaire pour Page1, Page2 & UnknownPage

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.htmlSANS 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!