embedio icon indicating copy to clipboard operation
embedio copied to clipboard

Client-side routing support in FileModule

Open rdeago opened this issue 4 years ago • 3 comments

Background and motivation

As reported by @madnik7 on Slack, EmbedIO doesn't have good support for SPAs with client-side routing, i.e. where there's only one HTML "entry point" page mapping paths to views via Javascript code.

If users always visit the home page first, there's no problem, as when they click a link to, say /login, it gets processed on the client side and no request ever reaches the server. There's no actual login.html file to serve; instead, the client-side router just loads and activates a "login" view.

The problems start when a user wants to load a view directly. Say I save a bookmark to my /dashboard page and later open my browser and click it:

  • the URL path doesn't start with either /api or any other path served by "functional" modules, so the requests slips path them;
  • the request finally reaches the FileModule serving the app's static files;
  • neither /dashboard nor /dashboard.html (in case there's a DefaultExtension set) are found by the file provider;
  • error 404!

As of version 3.4.3, the only workaround is to provide an OnMappingFailed callback that redirects requests to /, so at least the user sees something (namely the home page) they can navigate from. This is clearly not enough: the user explicitly requested the /dashboard page, so that's what they should get.

Other servers (Apache, ASP.NET Core applications, insert_your_preference_here) have no problem handling this situation, but it's a show-stopper for EmbedIO.

Proposed enhancement

When the requested URL path maps to a view in a SPA, the "main" HTML file should be served with no redirection occurring whatsoever, so the client-side code can see the path and act accordingly.

Or, to reformulate in a less application-specific fashion: an EmbedIO application should be able to decide which path is actually requested to the Provider of a FileModule, based upon the HTTP context's RequestedPath (plus possibly other factors, e.g. context Items).

Implementation proposals

Add a PreProcessPath callback to FileModule, whose signature is define by the following delegate:

using System.Threading.Tasks;

namespace EmbedIO.Files
{
    /// <summary>
    /// A callback used to obtain the actual path to be served by a <see cref="FileModule"/>, based upon the requested path
    /// (and other context data if desired).
    /// </summary>
    /// <param name="context">An <see cref="IHttpContext"/> interface representing the context of the request.</param>
    /// <returns>The path to serve.</returns>
    public delegate string FilePreProcessPathCallback(IHttpContext context);
}

The default callback should just return context.RequestedPath, thus replicating current behavior.

Usage examples

Let's assume we have a SPA with client-side routing, where paths (if not otherwise served by other modules) map to actual files if they have an extension, or to views if they have no extension. This is a rather common use case; other SPAs may have simpler rules (maybe an array of view names, if they are less than a dozen and not bound to change often) or more complex ones, but all it takes to support them is different code in the PreProcessPath callback.

Let's also assume that the SPA has a /404 view that shows an appropriate "not found" message.

var server = new WebServer(o => o
        .WithUrlPrefix(url)
        .WithMode(HttpListenerMode.EmbedIO))
    .WithLocalSessionManager()
    .WithWebApi("/api", m => m
        .WithController<MyWebApiController>())
    .WithModule(new MyWebSocketModule("/ws"))
    .WithStaticFolder("/", HtmlRootPath, true, m => m
        .WithContentCaching(UseFileCache)
        .WithPreProcessPath(ctx => {
            var path = ctx.RequestedPath;
            return string.IsNullOrEmpty(Path.GetExtension(path)) ? "/index.html" : path;
        }))
    .WithModule(new RedirectModule("/", "/404"));

Risks

  • If a PreProcessPath callback takes some time to complete, it can have a negative performance impact on the FileModule it is attached to. I defined it as synchronous (it returns string, not Task<string>) so hopefully people won't get weird ideas, such as querying a database with a table of view names.
  • If EmbedIO gains more traction because of better support for SPAs, we may have to cope with even more issues being opened about HttpListener's shortcomings. OK, just kidding... maybe.

rdeago avatar Oct 08 '20 09:10 rdeago

Good solution for SPAs from my point of view.

maarlo avatar Oct 08 '21 17:10 maarlo

Nice job! Actually, I had to do almost the same by customizing WebModuleBase.

madnik7 avatar Oct 08 '21 18:10 madnik7

Hi all, question, actually is there a way to handle this case with the current version?

osnoser1 avatar Aug 06 '22 06:08 osnoser1