2

A website I am working on is using Angular Universal 10.1.1 and is hosted on an IIS Server. An example of this happening is https://tragoa.com/welcome

If I navigate to the page from the root like foo.com then the website goes straight to the correct route foo.com/welcome without a 301 redirect. However, if I attempt to load foo.com/welcome directly then it will return that request with a 301 redirect to foo.com/welcome/ and that redirect request is returned with a 200 OK and then the trailing slash is stripped in the url bar. So it goes:

  1. Request with no trailing slash (/welcome)
  2. A 301 redirect returned to request with location including the trailing slash (/welcome/)
  3. 200 OK returned
  4. Trailing slash stripped in the URL bar in browser

The main issue here is the unwanted redirect

This only occurs when the page is using the prerendered HTML. It does not redirect if there is no prerendered index HTML for the given route.

This has caused issues with Lighthouse and some 3rd party redirect issues.

Does anyone know what could be causing this?

Some information that may help:

The base href of the html is <base href="/"> NOTE: I change this to not have the / then the routes get doubled up like foo.com/welcome/welcome

My server.ts get is this:

    import 'zone.js/dist/zone-node';
    
    import { ngExpressEngine } from '@nguniversal/express-engine';
    import * as express from 'express';
    import { join } from 'path';
    import { existsSync, readFileSync } from 'fs';
    
    import 'localstorage-polyfill';
    const domino = require('domino');
    let distFolder = join(process.cwd(), '../spa/browser');
    const template = readFileSync(join(distFolder, 'index.html')).toString();
    const win = domino.createWindow(template);
    win.Object = Object;
    win.Math = Math;
    global['window'] = win;
    global['document'] = win.document;
    global['branch'] = null;
    global['object'] = win.object;
    global['HTMLElement'] = win.HTMLElement;
    global['navigator'] = win.navigator;
    global['localStorage'] = localStorage;
    
    import { AppServerModule } from './src/main.server';
    import { APP_BASE_HREF } from '@angular/common';
    
    export function app(): express.Express {
      const server = express();
      const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index.html';
    
      server.engine('html', ngExpressEngine({
        bootstrap: AppServerModule,
      }));
    
      server.set('view engine', 'html');
      server.set('views', distFolder);
    
      server.get('*.*', express.static(distFolder, {
        maxAge: '1y'
      }));
    
      server.get(
        '*',
        (req, res) => {
        res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
      });
    
      return server;
    }
    
    function run(): void {
      const port = process.env.PORT || 4000;
    
      const server = app();
      server.listen(port, () => {
        console.log(`Node Express server listening on http://localhost:${port}`);
      });
    }
    
    declare const __non_webpack_require__: NodeRequire;
    const mainModule = __non_webpack_require__.main;
    const moduleFilename = mainModule && mainModule.filename || '';
    if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
      run();
    }
    
    export * from './src/main.server';

I use this command to create my site and then deploy the files "prerender": "ng build --configuration production && ng run my-app.spa:prerender:production"

  • 1
    did you find any solution, facing the same issue. – ahmed Sep 07 '22 at 15:27
  • 1
    Sadly not yet. Might be worth me putting a bounty on it at some point cause it would definitely help my Lighthouse scores. If you find a solution before then can you let me know? I just followed your question here as well: https://stackoverflow.com/questions/73666491/issue-with-angular-pre-rendering-with-universal-scully-for-seo – Eric Belisle Giddings Sep 13 '22 at 03:05
  • 1
    @ahmed I'm going to post these on the Angular Universal Github as issues since I know now it's not just me and my setup. I'll link here in an edit when done. – Eric Belisle Giddings Sep 17 '22 at 17:33
  • 1
    I posted this on the Angular Universal Github as well: https://github.com/angular/universal/issues/2827 – Eric Belisle Giddings Sep 17 '22 at 17:50

2 Answers2

3

I finally figured it out

Short answer: Because of the way angular universal pre-rendering works you can't remove "/" at the end for the pre-rendered pages, Your only solution is to make all your routes end with "/" by default, so when angular starts and is redirecting it will also redirect to the exact same url hence not counting as a redirect

Long answer:

Why is this happening?

First we need to understand why is this happening in the first place. So the way angular pre-rendering works is that for each route a directory/folder by the name of that route is generated with an "index.html" file inside. So lets say if I have a route "mywebsite.net/page1", angular universal will generate a directory "page1" with an index.html file inside containing the html code of that page. In both nginx and apache servers a directory is represented by a "/" relative to the base directory. So if the server want to access index.html file inside of that page1 folder the path would be represented as "/page1/", notice the trailing slash in the end meaning we are inside the page1 directory, alternatively if we had a "page1.html" file at the root directory that would be represented as "/page1" but that's not how angular universal generates it's pages, as mentioned above it generates directories with index.html files inside.

Why is there a 301 redirect

So when we first visit any route let's say "/page1", our server goes inside the page1 directory and opens up index.html file inside, and because it is a directory and not a file in the root directory it adds "/" in the end and once that index.html file is rendered the angular script runs it redirect you to the route you have defined in your angular routes, if it is without a "/" it will redirect to it, hence removing the slash. This is why slash is added and then removed. In your question you mentioned that chrome remove the "/" slash but that is not correct, angular does

Solution

The solution is to make all your routes end with a trailing slash by default. In you every module you will define your routes as

const routes: Routes = [
{ path: 'page1/.', component: page1Component, data: { title: "page q", subTitle: " " } },
...
];

notice "/." at then end of the path and in your href

<a href="" routerLink="/page1/.">Page1</a>

and in your main.ts you can add some logic to add a trailing slash to the links without one. something like this

import { Location } from '@angular/common';

const __stripTrailingSlash = (Location as any).stripTrailingSlash;
(Location as any).stripTrailingSlash = function _stripTrailingSlash(url: string): string {
  const queryString$ = url.match(/([^?]*)?(.*)/);
  if (queryString$[2].length > 0) {
    return /[^\/]\/$/.test(queryString$[1]) ? queryString$[1] + '.' + queryString$[2] : __stripTrailingSlash(url);
  }
  return /[^\/]\/$/.test(url) ? url + '.' : __stripTrailingSlash(url);
};

Doing all this fixes the redirect issue.

apologies for my bad english, I hope you can understand my explanation

ahmed
  • 421
  • 6
  • 13
  • 1
    Nice! I don't think the '/.' ending will work exactly for every route in my project. The analysis gave me an idea for how to possibly do it without altering routes in the Angular Router. I see what's happening is when a request goes to Express without a trailing slash that matches a prerendered route it returns a redirect message to get it at the file path (with a trailing slash). Evidenced by no redirect when requesting directly with a trailing slash. I'm going to try to intercept every request at the top of server.ts and add a trailing slash if it does not exist. What do you think? – Eric Belisle Giddings Sep 18 '22 at 22:33
  • I think I will try some of the logic from these to achieve it: https://stackoverflow.com/questions/13446030/rewrite-url-path-using-node-js & https://stackoverflow.com/questions/13442377/redirect-all-trailing-slashes-globally-in-express – Eric Belisle Giddings Sep 19 '22 at 00:06
  • So not a lot has worked so far but I did notice that the Angular Express Engine has this option: https://github.com/angular/universal/issues/2827#issuecomment-1251737169 & https://github.com/angular/universal/tree/main/modules/express-engine to put a url to use as the location so tried to take the `req.originalUrl + "/"`. I also noticed there's an option for serving static files called `redirect` and attempted to set it to false. I'm wondering if my local machine had heavy caching cause neither worked but it looks like they should? – Eric Belisle Giddings Sep 20 '22 at 14:39
  • This is caused by the line `server.get('*.*', express.static(distFolder, {maxAge: '1y'}));` if you go to the `serve-static` node module > `index.js` you can see where this is happening. Technically you should be able to stop it by adding `redirect: false` to the options but it does not seem to be picking up that the redirect is set to false in my attempts so far. – Eric Belisle Giddings Sep 20 '22 at 19:24
  • Finally found what to Google to bring up other answers lol here's someone else fixing it with that redirect false idea. Starting to think mine not working is a cache thing on my seerver. https://stackoverflow.com/a/60533554/5832236 – Eric Belisle Giddings Sep 20 '22 at 20:34
  • 1
    you can give it a try, I also tried setting "redirect: false" and it didn't worked for me either. The only solution that worked as mentioned was to make all my routes end with "/" – ahmed Sep 21 '22 at 04:48
  • Also asked here: https://github.com/expressjs/express/issues/4997 – Eric Belisle Giddings Sep 21 '22 at 16:15
  • What version of Angular Universal do you use? It looks like in 11.1 they implemented hybrid rendering. Wondering if this change will help. https://www.youtube.com/watch?v=RFwjJAZOzOA – Eric Belisle Giddings Sep 21 '22 at 19:40
  • Checkout my answer. It's why I was asking about your Angular Universal version. – Eric Belisle Giddings Sep 24 '22 at 05:51
1

This furthers the discoveries in the answer by @ahmed and offer a second (and possibly third solution).

Solution 1:

Before Angular Universal 11.1 there was no out-of-the-box hybrid rendering. This change made in 11.1 allowed for Angular Universal's renderer to handle both SSR and Prerendered pages if they exist. More information can be seen here: https://www.youtube.com/watch?v=RFwjJAZOzOA

Prior to this change there were two behaviors:

  1. The server running would return a non-prerendered page even though one exists
  2. The server would return a 301 Redirect to route to the path of the file with a trailing slash as noted in the question

With this hybrid rendering update if you route traffic as I have in the web.config or similarly for Apache servers the Angular Universal rendering engine will simply return the prerendered page if it exists or SSR it. No 301 redirects to the file path needed.

Solution 2

Some people have had success adding redirect: false as mentioned in this answer: https://stackoverflow.com/a/60533554/5832236 This is because the prerendered index.html files are static files.

That property is part of express.static and server-static This is where that redirect happens: https://github.com/expressjs/serve-static/blob/9b5a12a76f4d70530d2d2a8c7742e9158ed3c0a4/index.js#L202 and you can test if it is this redirecting you by changing that value temporarily in the node_modules

My suggested answer

Update Angular Universal to above 11.1 and make sure you're routing all traffic to the main.js server file to handle everything.

Here's the web.config that worked for me in addition to the server.ts in the question:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <webSocket enabled="true" />
    <handlers>
        <add name="iisnode" path="server/main.js" verb="*" modules="iisnode"/>
    </handlers>
    <staticContent>
      <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="100.00:00:00" />
      <mimeMap fileExtension="woff" mimeType="application/font-woff" />
      <mimeMap fileExtension="woff2" mimeType="application/font-woff2" />
      <mimeMap fileExtension=".json" mimeType="application/json" />
    </staticContent>
    <rewrite>
      <rules>
        <rule name="MainRule" stopProcessing="true">
          <match url=".*" />
          <conditions logicalGrouping="MatchAll">
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
          </conditions>
          <action type="Rewrite" url="server/main.js" />
        </rule>
      </rules>
    </rewrite>
    <security>
      <requestFiltering>
        <hiddenSegments>
          <remove segment="bin"/>
        </hiddenSegments>
      </requestFiltering>
    </security>
    <httpErrors existingResponse="PassThrough" />
  </system.webServer>
</configuration>