Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Questions on .NET Core 3.1 & Angular SSR spa.UseSpaPrerendering alternative

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?
like image 907
Cédric Berlez Avatar asked Jan 15 '20 08:01

Cédric Berlez


People also ask

What is NET Core 3.1 used for?

NET Core 3.1 is also known for Blazor, the ASP.NET Core technology that puts WebAssembly to use as a browser compilation target for higher-order programming languages so they can be used in Web development instead of JavaScript.

Is .NET Core 3.1 cross-platform?

NET Core is cross-platform. It runs on Windows, OS X and multiple distributions of Linux. It also supports different CPU architectures.

Is .NET Core 3.1 supported?

NET Core 3.1 was originally released on December 3, 2019 and is supported for three years. But the actual end of support day will be the closest Patch Tuesday starting that date, which is December 13, 2022.


1 Answers

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

like image 200
Juan Avatar answered Oct 05 '22 23:10

Juan