Technical | 2021-08-23

Secret Tricks for Path-Independent Angular Apps

Or: How I spent a whole week removing a slash from a URL.

Help with a supposedly simple quest.

Suppose you’re writing an Angular app and you want it to be available regardless of what URL you host it under. This is often desired when running multiple instances of the same app for different environments (like staging or testing) or when customers should be able to configure the path under which their on-premise instance is hosted.

So if the app is served at https://example.com/foobar it should work just as well as if it were served at https://example.com, without recompilation or any kind of dynamic routing. Getting this right should be easy but depending on your requirements, you might face a few intricate (and interesting!) pitfalls.

If you’ve ever been in that situation, you have discovered that it’s not so straightforward in Angular. Angular apps can run from arbitrary paths via two specific command line options: --base-href and --deploy-url. (They have different responsibilities but for the purposes of this post, we can always treat them the same.) However, the problem with those is, they are build parameters! If we want to change them, we have to rebuild the app, which is not always possible in the scenarios we want to support. For instance, it would be utterly impractical to ship source code to our customers and have them build it themselves. Additionally, rebuilding over and over again for different environments is simply redundant and violates best practices like the 12-factor app guidelines.

An Angular app being loaded in a browser. It only shows a blank screen.
This app should be available at http://localhost:4200/foobar… But we’re only getting a blank screen.

The Solution (most likely)

Fortunately, a simple solution exists that works for most requirements: Simply set the base-href and deploy-url parameters to ./ with the following build command:

ng build --prod --base-href ./ --deploy-url ./

This way we can “trick” Angular into using a relative path as their values. Effectively, they will always point to the current path at runtime.

An Angular app being loaded successfully in a browser. Then some Router navigation links are clicked to demonstrate that navigation works.
Now our example from above works!

As mentioned, this will work for any path. In the demo code behind the GIF above I’ve also made the app available under http://localhost:4200/baz and it just works without a rebuild. Go clone the GitHub repository of the blog post and give it a try!

About That Trailing Slash…

But wait! “Wouldn’t this blog post be over if it were that simple?”, I hear you ask. Well, yes there’s actually one tiny quirk to this solution that might cause you some problems. Admittedly, it’s hard to find and most people won’t have to deal with this at all because their web server will do it for them. But let me explain.

Notice how, in the last GIF, we’ve been redirected to /foobar/ when we typed /foobar and hit enter?

The initial HTTP request for an Angular app in the the browser's developer tools. It has been answered with a redirect to "/foobar".
We can see the redirect in the browser’s developer tools.

That redirect is done by our web server. The server recognizes that we’re requesting a resource that corresponds to a folder on disk and redirects us to the path with a trailing slash. On the next request it serves the index.html file generated by ng build and Angular starts up.

Okay but why is that important? Assume for a moment, we had a web server that did not perform this redirect. I’ve put together another reproducer for that which can be found in the “3-no-server-side-redirect” folder of the accompanying Git repository.

An Angular app being loaded in a browser at "http://localhost:4200/foobar" with a 404 response. Then the same app is loaded at "http://localhost:4200/foobar/" which works as expected.
Oh no, our app is broken, just like before! But… only if we access it without a trailing slash in the URL?

As you can see, the app only loads if we access it via http://localhost:4200/foobar/, not http://localhost:4200/foobar. So if you expect URLs without a trailing slash to work and you don’t have a web server that automatically performs the redirect to append the slash, you need a different solution.

Now, I know what you’re thinking: This is not a common scenario in production. But there are (more or less) legitimate reasons for this being a requirement of your project. It could be that you have to work with a weird custom server or you have to support arbitrary customer configurations where you can’t guarantee that the redirect to append the slash always happens. Or your client wants “clean URLs” without a trailing slash. In that case, stick around until the end, we did manage to get rid of that slash eventually.

Understanding the problem with base URLs

In order to understand how we can solve this problem we first need to understand what is actually going on when we hit that blank screen at http://localhost:4200/foobar. When we load an Angular app built with --base-href, Angular sets something called the document base URL in our browser. The document base URL is a URL that all relative URLs on the page (including those of links, stylesheets, scripts and script-triggered HTTP requests) are resolved onto. For example, given a document base URL of https://example.com/foo/, a relative URL bar/styles.css would resolve to https://example.com/foo/bar/styles.css. So far so good.

The problem lies in how base URLs without a trailing slash are interpreted: The same relative URL (bar/styles.css) with the same base URL without a trailing slash (https://example.com/foo) resolves not to the same final URL but in fact to https://example.com/bar/styles.css. This is analogous to how relative links are resolved on a web page: When you’re on the page https://example.com/foo.html and you click a link to bar.html you’re taken to https://example.com/bar.html.

When we load an Angular app built with --base-href ./, the document base URL is set to ./, which results in our current location (the URL in the browser UI) being used as the base. And as usual, without a trailing slash, the last path segment is truncated meaning that assets with relative URLs are loaded from the wrong path.

If this topic interests you here are some relevant links on MDN and in the WHATWG URL standard.

The Way Out: Patching Minified Bundles?!

So how can we circumvent this problem? One solution comes to mind that is almost canonical: If we have a base URL that’s set at build time, it must appear somewhere in the files we ship over HTTP, right? So we can just do a string replace over all of our app’s files to substitute the faulty base URL with the requested path and a trailing slash, if it did not already contain one.

The text of a minified bundle file generated by the Angular compiler. A small red circle marks a hard-to-spot occurrence of the value given for the "--base-href" build parameter.
Look, a wild occurrence of the base-href value in the minified bundle files.

Inconveniently, the base URL is not just set in one specific place in the bundle files generated by ng build and most of those files are heavily minified. So the only way to safely perform such a string substitution is to use a recognizable unique value for --base-href and --deploy-url instead of a “real” URL string. However, this still has the problem of potentially matching deliberate occurrences of the chosen unique string in our app. It also has one other major downside: You’re now locked into your specific string-replacing web server which is probably a dealbreaker for you at this point.

The Inline-Script Hack

A much cleaner solution is what I like to call the “inline-script hack”. It’s particularly useful because it works without any server support. In fact, it’s just a tiny code snippet that goes into your index.html file:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>MyApp</title>
    <script>
      document.write(`<base href="${location.pathname}${location.pathname.endsWith('/') ? '' : '/'}"/>`);
    </script>
    <!-- rest of index.html… -->
  </head>
</html>

Then build your app without --base-href or --deploy-url set and voilà!

An Angular app being loaded in a browser at "http://localhost:4200/foobar/", successfully.
Our example works again, even without server-side redirects!

But what does this do and why does it work? Again, we’ll have to take a dip into the standards to answer that question.

According to the WHATWG HTML standard, HTML parsers in browsers have to adhere to certain rules during their parsing operations. One of those rules dictates that, upon encountering a <script> tag, the parser must stop, execute the script in a JavaScript engine and wait for it to finish before parsing is resumed (which is kinda crazy but also cool). Moreover, the script can actually modify the document while running and the HTML parser then has to take those changes into account.

And what kinds of modifications could a script do to the document? Insert a <base> element of course which sets the document base URL! So that’s what our little inline script does: It appends a <base> element with a trailing slash in its href to the document while the HTML parser is stuck waiting for the script to complete. When the parser resumes it finds the <base> element right next to the <script> it just finished executing, as if it had always been there.

The DOM of an Angular app after initialization. A "base" element with the "src" attribute set to "/foobar/" can be seen right below a "script" tag.
The DOM in our example after initialization. Note that the <base> element was inserted by our script.

This solution is especially elegant because, according to the standard, the appending of the <base> element happens before the rest of the index file is even parsed - including the relative URLs it contains. So in a spec-compliant browser, we should not have a problem with loading assets from a wrong path.

An Angular app being loaded in a browser at "http://localhost:4200/foobar/", successfully. However, the browser's developer tools show that the app's assets are first loaded from a path without the base URL which results in some 404 requests. Then immediately afterwards the assets are loaded from the correct paths.
Should

Apparently both Firefox and Chrome start loading the document’s assets anyways even though they shouldn’t have parsed their URLs yet? I’m guessing that’s a performance optimization and they only fetch the assets and don’t, for example, start executing any scripts loaded this way. Still, it looks like they pretty much break the spec with this.

Now this might not be a problem for your application. As you can see in the GIF, the correct assets are loaded eventually and the app works just fine. But it might also be a serious problem: What if the assets do in fact exist at the location the browser initially tries to load them from? Could they interfere with the rest of the initialization procedure? Even worse, although in testing it appears wrongly loaded scripts are never executed, could it pose a security issue to pre-load assets you don’t know into your app’s environment using this method? Evaluate for yourself whether this is acceptable if you’re thinking about using this solution in production.

After Hours: Slash the Slash

Great, now we know how to deal with URLs that don’t end in a slash without any support from our web server. But if you look at the URL in the browser bar in the GIFs so far, it still ends with a slash every time, even though I promised to get rid of it eventually! So, what can we do about that?

Well, don’t ask me why but after a lot of fiddling, twiddling and digging through references I’ve found a way to actually, permanently remove the trailing slash from the URL in the browser UI when using Angular’s HashLocationStrategy. But it’s a pretty awful hack (even considering everything else discussed in this post) and I would never put it into production.

With that out of the way, here’s how I did it: First, I asked myself where the slash even comes from so I started looking through the source code of Angular Router. Turns out Angular never builds the full URL itself, instead it uses the history API to update the browser’s location on navigation events. The history API in turn respects the document base URL when converting relative to absolute URLs to be added to the browser history. And remember how our base URL looks now?

The DOM of an Angular app after initialization. A "base" element with the "src" attribute set to "/foobar/" can be seen right below a "script" tag.
Yep, there’s the slash.

That’s right! By adding the <base> element with the trailing slash, we’ve instructed the history API and in turn Angular Router to construct URLs with slashes at the end thereby adding the trailing slash back ourselves.

Cool, now we just need a way to get the history API to behave differently. The history API does not expose any settings that would help us in this case but there is a different API called “location”. The location API does something sort-of similar to the history API (for our purposes at least where we’re only manipulating the part of the URL after the hash, called the “fragment”) but it does not respect the document base URL.

So, imagine this (and forget code quality for a second): What if we could globally override the history API to proxy its calls to the location API instead? Well, this wouldn’t be JavaScript if we couldn’t make those dreams reality.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>BaseHrefDemo</title>
    <script>
      function overrideHistoryFunction(functionName) {
        const originalFunction = history[functionName].bind(history);
        history[functionName] = (data, title, url) => {
          if (url === '#/') {
            // Remove the part after the "#" if we don't need it.
            originalFunction('', title, location.pathname + location.search);
          } else if (url && url.startsWith('#')) {
            // If it's just a fragment change (i.e. by Angular Router), proxy to the location API.
            location.hash = url.substr(1);
          } else {
            originalFunction(data, title, url);
          }
        };
      }
      overrideHistoryFunction('pushState');
      overrideHistoryFunction('replaceState');

      document.write(`<base href="${location.pathname}${location.pathname.endsWith('/') ? '' : '/'}"/>`);
    </script>
    <!-- rest of index.html… -->
  </head>
</html>

In this snippet I’m overriding the history functions in use by Angular Router and proxying calls where only the fragment part is changed to the location API. As a bonus I’ve added a special case for the root route (#/) since I felt we don’t need the fragment part at all in that case.

An Angular app being loaded in a browser at "http://localhost:4200/foobar", successfully. Some Router links are clicked to demonstrate that navigation works. During the entire GIF the URL in the address bar stays without a trailing slash.
I’ve won but at what cost?

Aaand… it works! As far as I know at least. The location API is not the same as the history API. For example, the data and title parameters are unused when we proxy the call to the location API above. Perhaps there is a way around that but in general, it should go without saying that overriding an entire browser API to proxy its calls to a different one is kind of a bad idea in production.

Conclusion

Probably the main takeaway from this post is that, if you have a niche requirement, a mundane problem can become difficult. And interesting!

Most of the time you will not have the same exact problems I faced while working on the actual issues that led to this blog post. But if you do, I still recommend you work around the problem instead of implementing one of the solutions mentioned above (except for the first one). Everything here can be implemented in a better way if you can drop the requirement of supporting every possible server configuration.

Or maybe you’ve found a better solution? If so let us know by dropping us an email at hello@symflower.com. If you read until here, you should definitely also subscribe to our newsletter to be updated with the next exciting adventures at Symflower.