skip to Main Content

I created this post to get some insights from the community.
A little while ago with the release of .NET Core 3.0 the usage of the well-known and widely used spa.UseSpaPrerendering has been marked as Obsolete.

Around early-2019 I implemented SSR using .NET Core in a project that uses Angular but needed SEO and better loading perf.

1 year later (now, beginning of 2020) they want the same for a different project. But it already uses Core 3.1. immediately we noticed the Depricated flag, so I went searching for a way to do it ourselves.

From past experience the SSR problem had 2 parts, the first being getting your Angular app to actually be able to run in Server-side. So getting rid of or working around all the stuff that is unable to be executed in Server-side (working around usage of window API’s, by using isPlatform stuff in Angular). Second part was to actually get .NET Core to spin up the Angular CLI to start the actual pre-rendering. This was done using the UseSpaPrerendering.

Analysing the documentation that told us to figure it out ourselves and checking out my code from the past, things actually started to make sense.

I looked at the commands in my package.json file the 2 main commands executed were build:ssr to actually pre-compile the whole server/main.js next to the browser/… files. The second command was the serve:ssr, which was going to be executed by the UseSpaPrerendering code (at least that is what I assume).

In practice, our CI/CD would go and execute the build:ssr and publish all the files to the App server running .NET Core runtime. and using the UseSpaPrerendering code it would then execute the serve:ssr.

Now jumping forward towards the present where I need to find a solution. I figured that I could also just run the necessary commands myself. So after excluding some of the non-SSR compatible code in my Angular I ran the build:ssr command myself, followed by the serve:ssr command. which worked, my Angular app was SSR rendered, by served by node itself rather than by .NET Core.

Next step was that I tried to that in my .NET Core Startup file. for now I did the build:ssr myself (because in production it would be done by CI/CD) and I re-wrote the start script in my package.json to run the command npm run serve:ssr. I started that command using the spa.UseAngularCliServer(npmScript: "start"); code in my startup.cs and there I had it, my .NET Core runtime starts up both my API and my SSR Angular app.

So far so good, but only 1 problem now. my SSR Angular is hosted on port 4000 default and also listens to that port (I can see that in my output) and my API listens on port 5000(http) and 5001(https).

So now I have a couple of questions about this:

  1. Is this the correct way of doing this now?
  2. How can I make sure that in production, when one goes to my app, that the node listener will kick in?
  3. Would it be better if I just completely separated my .NET Core API and Angular SSR app completely ? And Thus also deploy them separately?

2

Answers


  1. I’m struggling with netcore 3.1 and angular too when it comes to deploy the project on azure or anything.. did you find anything? Could you provide your startup file?
    when I use dotnet publish, package.json is not copied to publish/ClientApp directory so the command used by spa.UseAngularCliServer() fails or it just doesn’t find /index.html.

    for now, I run my project locally like this:

    app.UseSpa(spa =>
      {
        spa.Options.SourcePath = "ClientApp";
        if (env.IsDevelopment())
        {
          spa.UseAngularCliServer(npmScript: "dev:ssr");
        }
      });
    
    Login or Signup to reply.
  2. To anyone facing this issues, I’ve just solved it and here is our solutions but there are few facts:

    • [Web.config] Node Context, I mean the Process Working Directory, works different in iisnode, PWD is the target file path, this means that if your main.js is within dist/server/main.js then the paths relative to browser won’t be dist/browser/ but ../browser/
    • Consider that during deployment you will have to generate Web.config according to this new structure

      -Handler iisnode -NodeStartFile dist/server/main.js -appType node

    • [server.ts] – Having that in mind consider also to set the browser path according to your runtime environment so that if you are in production it should be ../browser

    • [server.ts] – Order matters in server.ts. IF YOU FACE BROWSER API ISSUES it is because “import { AppServerModule } from ‘./main.server‘;” MUST be placed AFTER domino declarations.

    Here is a working example on a server.ts that is also using i18n redirections according to url requests with a locale string (now that I solved this i18n issues too it I can tell you that it worth to read the docs).

    /***************************************************************************************************
     * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
     */
    import { APP_BASE_HREF } from '@angular/common';
    import '@angular/localize/init';
    import { ngExpressEngine } from '@nguniversal/express-engine';
    import * as express from 'express';
    import { existsSync } from 'fs';
    import { join } from 'path';
    import 'zone.js/dist/zone-node';
    import { environment } from './environments/environment';
    
    // THIS FIX MOST OF THE COMMON ISSUES WITH SSR:
    // FIRST SET THE BROWSER PATH ACCORDING TO RUNTIME ENVIRONMENT
    let browserPath;
    if (environment.production) {
      browserPath = '../browser';
    } else {
      browserPath = 'dist/browser';
    }
    const enDistFolder = join(process.cwd(), browserPath + '/en');
    
    // Emulate browser APIs
    const domino = require('domino');
    const fs = require('fs');
    const templateA = fs.readFileSync(join(enDistFolder, 'index.html')).toString();
    
    const win = domino.createWindow(templateA);
    console.log('win');
    win.Object = Object;
    console.log('Object');
    win.Math = Math;
    console.log('Math');
    
    global['window'] = win;
    global['document'] = win.document;
    global['Event'] = win.Event;
    console.log('declared Global Vars....');
    
    /****************************************************/   
    /** NOTE THIS: I need to avoid sorting this line */
    // USE CTRL+P -> SAVE WITHOUT FORMATTING
    import { AppServerModule } from './main.server';
    /****************************************************/
    
    // The Express app is exported so that it can be used by serverless Functions.
    export function app() {
      const server = express();
      const indexHtml = existsSync(join(browserPath, 'index.original.html')) ? 'index.original.html' : 'index.html';
    
      // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
      server.engine('html', ngExpressEngine({
        bootstrap: AppServerModule,
      }));
    
      server.set('view engine', 'html');
      server.set('views', browserPath);
    
      // Example Express Rest API endpoints
      // server.get('/api/**', (req, res) => { });
      // Serve static files from /browser
      server.get('*.*', express.static(browserPath, {
        maxAge: '1y'
      }));
    
      server.use('/robots.txt', express.static('/en/robots.txt'));
      server.use('/ads.txt', express.static('/en/ads.txt'));
    
      // THE ORIGINAL Universal Requests handler
      // // // All regular routes use the Universal engine
      // // server.get('*', (req, res) => {
      // //   res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
      // // });
    
      // OUR i18n REQUESTS HANDLER
      // All regular routes use the Universal engine
      server.get('*', (req, res) => {
        // this is for i18n
        const supportedLocales = ['en', 'es'];
        const defaultLocale = 'es';
        const matches = req.url.match(/^/([a-z]{2}(?:-[A-Z]{2})?)//);
    
        // check if the requested url has a correct format '/locale' and matches any of the supportedLocales
        const locale = (matches && supportedLocales.indexOf(matches[1]) !== -1) ? matches[1] : defaultLocale;
    
        res.render(`${locale}/index.html`, { req });
      });
    
      return server;
    }
    
    function run() {
      const port = process.env.PORT || 4000;
    
      // Start up the Node server
      const server = app();
      server.listen(port, () => {
        console.log(`Node Express server listening on http://localhost:${port}`);
      });
    }
    
    // Webpack will replace 'require' with '__webpack_require__'
    // '__non_webpack_require__' is a proxy to Node 'require'
    // The below code is to ensure that the server is run only when not requiring the bundle.
    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 './main.server';
    

    I still need to work a bit on this code and in our app (SSR and oauth issues, another funny topic) but I want to share it because it took us almost 20 deployments to fix these issues.

    Final words: if you come here after an angular 8 migration I’ll be glad to help you and give you nice hints but, honestly, follow the guide and read carefully the docs. Also, if you are using Azure DevOps pipelines, you should consider using an npm cache. Our as is large and we are now saving more than 12 minutes on each build process (That is a huge amount of time, isn’t it?) Feel free to get in touch with me.

    Juan

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search