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.
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
usesrenderModule
exported from the server build, saves the file in a sub folder under client (with nameindex.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:
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
}
Now the worker PreRender
function:
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?
RESOURCES
- Angular --- Prerendering Static Pages
- Angular universal prerender builder source code
- Piscina worker
- Skimmed down version of the builder --- Gist
- Ora logger
- Stackblitz project
Top comments (0)