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.
anchorPrerendering 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.
anchorPrerender 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 routeworker.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 ownnpm
packages, for simplicity. If you want to run the same (or skimmed) builder code from your sub folder, the one key intsconfig
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.
anchorTwisting 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?