DEV Community 👩‍💻👨‍💻

Cover image for Prerendering in Angular  - Part III
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Prerendering in Angular  - Part III

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
Enter fullscreen mode Exit fullscreen mode

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

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();
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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

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
}
Enter fullscreen mode Exit fullscreen mode

Now the worker PreRender function:

In our schema.json we only need the following props

// schema props
{
  browserTarget,
  serverTarget,
  routes // array of routes
}
Enter fullscreen mode Exit fullscreen mode

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
}; 
Enter fullscreen mode Exit fullscreen mode

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?

RESOURCES

RELATED POSTS

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.