Sekrab Garage

Using Angular builder

Prerendering in Angular - Part III

Angular October 3, 22
Series:

Angular prerendering

There are many reasons to prerender, and many not to. Each application finds its own use case to prerender, while static site generation is all about prerendering, a huge website like stackoverflow avoids it. Angular recently added universal builder for prerendering, but in this series, I want to investigate other ways.
  1. 3 months ago
    Prerender routes in Express server for Angular - Part I
  2. two months ago
    Prerendering in Angular - Part II
  3. two months ago
    Prerendering in Angular - Part III
  4. two months ago
    Prerendering in Angular - Part IV

Angular recently added package for prerendering, which allows us to create prerendered pages without building a server. Let us go through the basics of it first before we create our spin-off.

Prerendering for browser-only applications

To run the prerender feature we have to at least install the following packages

// npm packages
@angular/platform-server
@nguniversal/common

// dev dependencies
@nguniversal/builders

In angular.json setup few targets, one for browser build, one for server build, and one for prerender builder.

// angular.json
{
  "newProjectRoot": "projects",
  "projects": {
    "cr": {
      // ...
      "prefix": "cr",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            // ... the usual options for browser
          },
          "configurations": {
            "production": {
              "outputPath": "./host/client/",
              "index": "src/index.html",
	            // ... other
            }
          }
        },
        "server": {
          "builder": "@angular-devkit/build-angular:server",
          "options": {
            "outputPath": "./host/server/",
            "main": "server.ts",
            "tsConfig": "tsconfig.server.json",
	           // ...
            }
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "optimization": false,
		          // ...
            }
        },
        "prerender": {
          "builder": "@nguniversal/builders:prerender",
          "options": {
            "browserTarget": "cr:build:production",
            "serverTarget": "cr:server:production",
            "guessRoutes": false, // guessing outputs all routes
            "routes": [
              "/projects/create",
              "/projects/1",
              "/example",
              "/" // this will generate index.original.html in the client folder
            ]
          }
        }
			}
    }
  }
}

Assuming this is only a browser platform application, and there is no express server to serve the files, and assuming the default tsconfig.server.json that has server.ts as the main file to compile, the server.ts need not have anything but the following:

// bare minimum server.ts, there is no need for an express server if you are not
// going to use SSR in the wild
// no need for a main.server,ts
import 'zone.js/dist/zone-node';
import { enableProdMode} from '@angular/core';
import { environment } from './src/environments/environment';

// following lines is for prerender to work
export { AppServerModule } from './src/app/app.server.module';
export { renderModule } from '@angular/platform-server';

// if you somehow use 'window' property loosely 
// (or any global properties that work only in browser)
global.window = undefined;

// if you use localStorge on the client, you might want to add this
global.localStorage = {
  key: function(index) {
    return null;
  },
  getItem: function (key) {
    return null;
  },
  setItem: function (key, value) {
  },
  clear: function () {
  },
  removeItem: function (key) {
  },
  length: 0
};

if (environment.production) {
   enableProdMode();
}

The AppServerModule is usually under /src/app/app.server.module.ts, and it is the default that comes with Angular CLI:

// src/app/app.server.module.ts
// basic 
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
  ],
  providers: [
    // Add server-only providers here.
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Now run ng run cr:prerender. This will generate two folders in the host output folder, client and server. The client folder will hold the static files to prerender, and it is the folder you should be deploying to your browser-only host. The server is not for use.

If you have SSR in place, your server.ts should have the ngExpressEngine as usual, but none of it affects the prerendering process.

That is the out-of-the-box builder. But…

Libraries are written for 16 personality types, and usually misses the one scenario you need.
I said.

Prerender builder local version

The above builder starts with the scheduling a browser and a server build with @nguniversal/builders. It generally is a good library, once you figure out what you’re doing, but the one feature I personally wanted to have is to use it “post” build, to control which index.html file to serve. This helps us in our malicious intentions to replace Angular localization package with our own multilingual single-build app. So before we build our solution, let’s open the box and find out if we can have our own spin-off.

  • index.ts runs two builders first then iterates through routes and runs a Piscina worker to render every route
  • worker.ts uses renderModule exported from the server build, saves the file in a sub folder under client (with name index.html)
  • It also saves a copy from the original index, into index.original.html, this won’t be necessary when we feed our own unique index file (next episode).
  • The builder uses ora library for logging out on console
  • It is written in CommonJs, and uses dynamic imports to import the server build
  • It takes care of serviceWorker. I will remove this part to simplify. (One fine Tuesday we’ll dig into service workers.)

The first attempt is to reduce the schedule builder statements so that it only uses what was already built. I have created a gist with the skimmed down version of the builder, removing the parts where a build goes through scheduleBuilder.

Note: As usual, we create a subfolder for the builder, and let it have its own npm packages, for simplicity. If you want to run the same (or skimmed) builder code from your sub folder, the one key in tsconfig that made the difference was "esModuleInterop": true

The variables needed for the worker are:

// index.ts main builder function
export async function execute(
  options: IOptions,
  context: BuilderContext,
): Promise<BuilderOutput> {
	// ...
	return _renderUniversal(
    routes, // we will pass these as an array
    context, // given in builder
    browserResult, // to replace
    serverResult, // to replace
    browserOptions, // to find out what's needed
    options.numProcesses, // hmm, okay for now
  );
}

The browserResult and serverResult models give us a hint to which variables we need to provide, without scheduling a build.

// the least number of properties to replace
const [platform]Result = {
  // in multiple builds for localization (Angular default behavior), this would be populated
  // in our case, it is just one
  outputPaths: [outputPath],
  
  // the following are identical, outputPath is never used
  // we need two values one for browser and one for server
  baseOutputPath: outputPath,
  outputPath: outputPath,
  
  // from BuilderOutput
  success: true
}

As for browserOptions:

// browserOptions used
// to form the outPath and baseOutputPath
browserOptions.outputPath

// to read the path base of the index file
browserOptions.index

// to minifyCss and add inline critical CSS
// we can assume this to be true always
browserOptions.optimization

// the following we will not make use of
browserOptions.deployUrl // for inline critical CSS, deprecated
browserOptions.tsConfig // for guessRoutes
browserOptions.serviceWorker // for serviceWorker
browserOptions.baseHref // serviceWorker
browserOptions.ngswConfigPath // serviceWorker

To get the baseOutputPath we can use the target options as follows:

export async function execute(
  options: IOptions,
  context: BuilderContext,
): Promise<BuilderOutput> {

  // read browser options and setup the clientPath
  const browserTarget = targetFromTargetString(options.browserTarget);
  const browserOptions = (await context.getTargetOptions(browserTarget)) as any;
  const clientPath = path.resolve(context.workspaceRoot, browserOptions.outputPath );
  
  // and server options
  const serverTarget = targetFromTargetString(options.serverTarget);
  const serverOptions = (await context.getTargetOptions(serverTarget)) as any;
  const serverPath = path.resolve(context.workspaceRoot, serverOptions.outputPath);

	// now we do not need to schedule any builder
	
	return _renderUniversal(
    routes,
    context,
		// now we pass strings for paths instead of browserResult and serverResult
    clientPath,
    serverPath,
		// we just need the index file from options
    getIndexOutputFile(browserOptions),
		// since we are dropping piscina this won't be necessary
		// options.numProcesses
  );
}

The basic _renderUniversal function: we will simplify and assume a single language, so there will be no loop in outputPaths

// in index.ts, simplify
async function _renderUniversal(
  routes: string[],
  context: BuilderContext,
  clientPath: string,
  serverPath: string,
  indexFile: string
): Promise<BuilderOutput> {

	// the server bundle is server/main.js
  const serverBundlePath = path.join(serverPath, 'main.js');

	// no more loop into outputPaths
  if (!fs.existsSync(serverBundlePath)) {
    throw new Error(`Could not find the main bundle: ${serverBundlePath}`);
  }
  context.logger.info(`Prerendering ${routes.length} route(s) to ${clientPath}...`);

 try {
		// reduced the two tries into one
		// this map is alright, I'll keep it
    const results = (await Promise.all(
      routes.map((route) => {
				// removed deployUrl, and assume css related props
        const options: RenderOptions = {
          indexFile,
          clientPath, // this is outputPath
          route,
          serverBundlePath
        };
				// direct import from worker, no more Piscina worker
        return PreRender(options);
      })
    )) as RenderResult[];
    let numErrors = 0;
    for (const { errors, warnings } of results) {
      errors?.forEach((e) => context.logger.error(e));
      warnings?.forEach((e) => context.logger.warn(e));
      numErrors += errors?.length ?? 0;
    }
    if (numErrors > 0) {
      throw Error(`Rendering failed with ${numErrors} worker errors.`);
    }
    context.logger.info(`Prerendering routes to ${clientPath} complete.`);

  } catch (err) {
    context.logger.error(`Prerendering routes to ${clientPath} failed.`);
    return { success: false, error: err.message };
  }
  return { success: true};
}

In worker.ts we will export the main function PreRender, the RenderOptions can be simplified

// simplified RenderOptions
export interface RenderOptions {
  indexFile: string;
  clientPath: string;
  serverBundlePath: string;
  route: string
}

Now the worker PreRender function:

// worker.ts
export async function PreRender({
  indexFile,
  clientPath,
  serverBundlePath,
  route,
}: RenderOptions): Promise<RenderResult> {

  const result = {} as RenderResult;

  // this is the index.html
  const browserIndexOutputPath = path.join(clientPath, indexFile);

  // correct route folder
  const outputFolderPath = path.join(clientPath, route);

  // add route/index.html 
  const outputIndexPath = path.join(outputFolderPath, 'index.html');

  // read index file
  let indexHtml = await fs.promises.readFile(browserIndexOutputPath, 'utf8');

  // change this for critical css
  // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
  indexHtml = indexHtml.replace(
    / media="print" onload="this\.media='all'"><noscript><link .+?><\/noscript>/g,
    '>',
  );

	// now get those out of the server bundle, 
	// notice the dynamic import
  const { renderModule, AppServerModule } = await import(serverBundlePath);
  
	// run renderModule
  let html = await renderModule(AppServerModule, {
    document: indexHtml,
    url: route,
  });

  // inline critical css, copied as is
	// notice the universal common tools are in the parent folder
	// if not, you need to install it
  const { ɵInlineCriticalCssProcessor: InlineCriticalCssProcessor } = await loadEsmModule<
    typeof import('@nguniversal/common/tools')
  >('@nguniversal/common/tools');

  const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
    deployUrl: '',
    minify: true
  });

  const { content, warnings, errors } = await inlineCriticalCssProcessor.process(html, {
    outputPath: clientPath,
  });

  result.errors = errors;
  result.warnings = warnings;
  html = content;

  // create folder if needed, then write file to index
  fs.mkdirSync(outputFolderPath, { recursive: true });
  fs.writeFileSync(outputIndexPath, html);

  return result;
}

In our schema.json we only need the following props

// schema props
{
	browserTarget,
	serverTarget,
	routes // array of routes
}

Running, it works as expected but fails when it references our global object. The solution is to add the necessary global references to the worker file (outside any scope):

// worker.ts, add global references
global.window = undefined;
global.localStorage = {
  key: function (index) {
    return null;
  },
  getItem: function (key) {
    return null;
  },
  setItem: function (key, value) {
  },
  clear: function () {
  },
  removeItem: function (key) {
  },
  length: 0
}; 

The other needed files are in StackBlitz prerender-builder folder, including the adjustment to angular.json. Now we transpile (in StackBlitz that’s running npx tsc inside our sub folder), and then run:

ng run cr:prerender

Checkout the output folder host-builder in StackBlitz. The static files were created in the client folder as expected.

Twisting and turning

Now we need to provide our own index.[lang].html and create our language subfolders, in addition to passing global locale files. For that, let’s meet next episode. 😴

Thank you for reading this far, I hope it was not so confusing, was it?

  1. 3 months ago
    Prerender routes in Express server for Angular - Part I
  2. two months ago
    Prerendering in Angular - Part II
  3. two months ago
    Prerendering in Angular - Part III
  4. two months ago
    Prerendering in Angular - Part IV