Flutter Web - 404 - Direct URL - No Hash

Compatibility
Last Reviewed
May 20, 2020
Published on
Feb 19, 2020
Flutter
v 1.14.6

Introduction

When I had to deploy my first Flutter Web application in production, I had to handle all the usual features, logically related to Web Server and especially:

  • the famous "page not found 404"
  • the direct input of the URL from the Browser

I searched a lot the Internet but never found any good solution.

This article explains the solution I implemented...


Side note on May 05, 2020

As I said when I posted this article, things are evolving quite fast in the Flutter world and my good friend Simon Lightfood issued the following Pull Request, which might give a Standard Solution to the problem I describe in this article. This PR is still under validation but keep an eye on it.


Background information

This article has been written in February 2020 and is based on version 1.14.6 of Flutter, running of Channel Beta.

Having a look at the Flutter Roadmap 2020, Flutter Web should be officially released this year with a consequence that this article might not be relevant quite soon, as the problem it addresses might be solved in the coming months.

I also tried to play a bit with the Service Workers, but could not find any solution that way neither.

Before giving you the solution I have implemented, I would like to share with you some important pieces of information...


Reminder - Flutter Web application does NOT run behind a fully configurable Web Server

"Flutter Web Application does NOT run behind a fully configurable Web Server"

This statement is very important and very often forgotten...

Indeed, when you run a Flutter Web Application, you "simply" launch a basic web server that listens to a certain "IP_address:port" and serves the files located in the "web" folder. Very little configuration/customization can be added to the instance of that web server.


Different web folders

If you run the Flutter Web App in debug mode, the web folder is "/web"

If you run in release mode, the web folder is "/build/web"

When you run your Flutter Web Application, once the basic web server is activated, the "index.html" page is automatically invoked, from the corresponding "web" folder.

The "index.html" page automatically loads some assets as well as the "main.dart.js" file that corresponds to the whole application. In fact, this corresponds to the Javascript transposition of your Dart code, and some other libraries.


So in other words...

When you get access to the "index.html", you are loading the whole application.

This means that a Flutter Web Application is a Single Page Application and, in most cases, except to retrieve any additional assets (fonts, images...) once that Single Page Application has been loaded and launched, there will no longer be any interactions between your Flutter Web Application (running on the Browser) and the web Server.


The weird '#' character in the URL

When you are running a Flutter Web Application and you are navigating from one page (= Route) to another, I suppose you have already noticed the change at the level of the Browser's URL navigation bar...

As an example, suppose that your application consists of 2 pages: the HomePage and a LoginPage. The HomePage is automatically displayed at application's launch and has a button to navigate to the LoginPage.

The browser's URL bar will contain:

The hashtag specifies the URL fragment, which is commonly used in Single Page Applications for navigation as an alternative to URL paths.

The most interesting thing about the URL fragment is that

Fragments are NOT sent in HTTP Request Messages because fragments are only used by the browsers.

In our case, in Flutter Web, they are used by the browser in order to handle the history

(for more information about Fragments, please follow this link)


How to hide the '#' character in the URL?

Many times I saw this question on the Internet and the answer is very straightforward.

As the '#' character usually corresponds to a Page (= Route) in your application, you need to tell the browser to update the URL while keeping on considering the page in the Browser history (so that the Browser's back and forward buttons will work correctly).

To achieve this, you need the page to be a "StatefulWidget" in order to take the benefit of the initialization time of the page (= initState method).

The code to achieve this is the following:



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

From that moment on, when the user will be redirected to "MyPage", rather than displaying "http://192.168.1.40:8080/#/MyPage" in the URL, the browser will show "http://192.168.1.40:8080/mypage", which is more userfriendly.

However, if you bookmarked that page and try to recall it, or directly type that URL in your browser, you will be faced with the following error page "This http://192.168.1.40 page can't be found", which corresponds to the famous HTTP error 404.

So how to solve this?


Each time you access the Flutter Web Application via an URL that has been entered manually, the main() is run

Before explaining the solution, it is also important to note that when you enter a "valid" URL via the Web Browser, an access is made towards the Flutter Web server to reload the application and, once loaded, the main() method is run.

In other words, if you manually enter "http://192.168.1.40:8080" or "http://192.168.1.40:8080/#/page" at the level of the Web Browser URL bar, a request is sent to the Web Server which reloads the Application and will eventually run the "main()" method.

This is not true when, via the application itself, you switch from one page (= Route) to another of the application as the code is only run at the level of the Web Browser!!


My Solution

first attempt... not a solution...

The following article already discussed the issue in the past and gave some hints of a solution BUT the "Best Solution (so far)" exposed in that article does no longer work today (or I haven't been able to make it work).

So a first solution which directly came to me was based on the "Second solution" as explained in that very same article, where:

  • we mention the ".html" extension when invoking the pushState in the initState() method as follows: html.window.history.pushState(null, "MyPage", "/mypage.html");

  • we create one *.html page per screen...

But of course, this is tedious and error-prone therefore I continued my investigations.

The solution

Then I thought: "what if I could intercept the URL request and redirect it with the correct format?".

In other words, something like... (but this does not work)



Unfortunately, as I said earlier, it is not possible to relay the notion of fragment (with the # character) in HTTP requests.

Therefore, I needed to find something else.

What if I could make the application "think" the URL was different?

Then I found the Shelf Dart package, a Web Server middleware for Dart, which allows the definition of request handlers.

The solution was then straightforward:

  • We run an instance of the shelf Web Server, listening to all incoming requests
  • We run Flutter Web on the localhost
  • We check if a request refers to a page
  • For all these requests, we redirect them to the nominal index.html, KEEPING the Request URL unchanged, so that it can be intercepted by the main() method, which then displays the requested page...

Of course, requests related to assets (images, javascript...) should not be part of the redirection...

Something like this:



Once again, shelf provides a proxy handler, called shelf_proxy, which proxies requests to an external server. Exactly what I needed!

However, this proxy handler does not offer any feature to insert re-routing logic... too bad.

Therefore, as its source code is under BSD license, I cloned the source code of that proxy handler to insert my own re-routing logic, which simply consists in (but of course, this could be extended to the needs):

  • if the URL does not contain any reference to an extension (e.g. ".js", ".json", ".png"...) and,
    contains only 1 chunk in the path (e.g. "http://192.168.1.40:8080/mypage" but NOT "http://192.168.1.40:8080/assets/package/..."), then

    • I redirect the request to the page "index.html" of my instance of Flutter Web server,
  • else, I simply redirect the request to my instance of Flutter Web server without mentioning the "index.html" page.

"This means running 2 Web Servers!", will you tell me

Yes it does.

  • The Proxy Web Server (here, using Shelf), listening to the real IP Address & Port
  • The Flutter Web Application, listening to localhost

Implementation

1. Create your Flutter Web Application

Create your Flutter Web Application as usual.

2. Adapt your 'main.dart' file (in /lib)

The idea is to directly capture the path that was provided in the Browser 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

Explanation:

  • at the level of the main() method, we capture the path that was submitted (line #8) and provide it to the Application
  • the Application considers that path as the "initial one" => "initialRoute: pathName," (line #30)
  • the Routes.onGenerateRoute(...) method is then called and returns the Route which corresponds to the provided path
  • if the Route does not exist, it redirects to UnknownPage()

3. Create the Proxy Server

1 -- create a bin folder, in the project's root directory

2 -- create a file, called "proxy_server.dart" in the /bin folder

3 -- put the following code inside that "proxy_server.dart" file:



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

Explanation:

The main() method simply initializes an instance of shelf web server, which

  • listens to "localhost", on port 8080
  • sends all the incoming HTTP requests to the proxyHandler() method, which is instructed to redirect to "localhost:8081"

4 -- copy the following file "proxy_handler.dart" from this gist to your /bin folder.



Explanation:

The only thing to explain here is the conditional redirection (lines #44-46, #143-160)



...
if (_needsRedirection(requestUrl.path)){
    requestUrl = Uri.parse(url + "/index.html");
}
...

which simply instructs the routine to redirect the HTTP request to the "/index.html" page WITHOUT modifying the Request URL which is conveyed by the HTTP request (so, "http://localhost:8080/page1" for example). This is that request path ("/page1") that will be intercepted by the main() method of the Flutter Web Application.


How to run this solution?

1 -- Start the Flutter Web Application, listening to "localhost" and port "8081", for example:

(debug) flutter run -d web --web-port 8081 --web-hostname localhost
(release) flutter run -d web --release --web-port 8081 --web-hostname localhost

2 -- Start the Proxy Web Server

dart bin/proxy_server.dart


Conclusions

As I needed to release a Flutter Web Application in production, I had to find a solution that was able to handle:

  • URL exceptions (such as "Page not Found - error 404");
  • friendly URL (without the # character)

The solution I put in place (the subject of this article), works but this can only be seen as a workaround.

I suppose there should be other solutions a little more "official" but I have found no other to date.

I really hope that the Flutter Team will soon be able to solve this problem, so that a "clean" solution exists or documents it in case it is already available.

Stay tuned for new articles, meanwhile let me wish you a happy coding!

0 Comments
Be the first to give a comment...
© 2024 - Flutteris
email: info@flutteris.com

Flutteris



Where excellence meets innovation
"your satisfaction is our priority"

© 2024 - Flutteris
email: info@flutteris.com