Skip to content

Instantly share code, notes, and snippets.

@ayyash
Last active September 30, 2022 12:09
Show Gist options
  • Save ayyash/2775f0c24ef09f0036095f2c0d0a3c4a to your computer and use it in GitHub Desktop.
Save ayyash/2775f0c24ef09f0036095f2c0d0a3c4a to your computer and use it in GitHub Desktop.
Skimmed down version of nguniversal prerender builder
import {
BuilderContext,
BuilderOutput,
createBuilder,
targetFromTargetString,
} from '@angular-devkit/architect';
// import { BrowserBuilderOptions } from '@angular-devkit/build-angular';
// import { normalizeOptimization } from '@angular-devkit/build-angular/src/utils/normalize-optimization';
// import { augmentAppWithServiceWorker } from '@angular-devkit/build-angular/src/utils/service-worker';
import * as fs from 'fs';
import ora from 'ora';
import * as path from 'path';
import Piscina from 'piscina';
import { promisify } from 'util';
import { PrerenderBuilderOptions, PrerenderBuilderOutput } from './models';
import { RenderOptions, RenderResult } from './worker';
export const readFile = promisify(fs.readFile);
type BuildBuilderOutput = BuilderOutput & {
baseOutputPath: string;
outputPaths: string[];
outputPath: string;
};
type ScheduleBuildsOutput = BuilderOutput & {
serverResult?: BuildBuilderOutput;
browserResult?: BuildBuilderOutput;
};
async function getRoutes(
options: PrerenderBuilderOptions
): Promise<string[]> {
let routes = options.routes || [];
routes = routes.map((r) => (r === '' ? '/' : r));
return [...new Set(routes)];
}
function getIndexOutputFile(options: any): string {
if (typeof options.index === 'string') {
return path.basename(options.index);
} else {
return options.index.output || 'index.html';
}
}
/**
* Schedules the server and browser builds and returns their results if both builds are successful.
*/
// async function _scheduleBuilds(
// options: PrerenderBuilderOptions,
// context: BuilderContext,
// ): Promise<ScheduleBuildsOutput> {
// const browserTarget = targetFromTargetString(options.browserTarget);
// const serverTarget = targetFromTargetString(options.serverTarget);
// const browserTargetRun = await context.scheduleTarget(browserTarget, {
// watch: false,
// serviceWorker: false
// });
// const serverTargetRun = await context.scheduleTarget(serverTarget, {
// watch: false,
// });
// try {
// const [browserResult, serverResult] = await Promise.all([
// browserTargetRun.result as unknown as BuildBuilderOutput,
// serverTargetRun.result as unknown as BuildBuilderOutput,
// ]);
// const success =
// browserResult.success && serverResult.success && browserResult.baseOutputPath !== undefined;
// const error = browserResult.error || (serverResult.error as string);
// return { success, error, browserResult, serverResult };
// } catch (e) {
// return { success: false, error: e.message };
// } finally {
// await Promise.all([browserTargetRun.stop(), serverTargetRun.stop()]);
// }
// }
/**
* Renders each route and writes them to
* <route>/index.html for each output path in the browser result.
*/
async function _renderUniversal(
routes: string[],
context: BuilderContext,
browserResult: BuildBuilderOutput,
serverResult: BuildBuilderOutput,
browserOptions: any,
numProcesses?: number,
): Promise<PrerenderBuilderOutput> {
const projectName = context.target && context.target.project;
if (!projectName) {
throw new Error('The builder requires a target.');
}
// const projectMetadata = await context.getProjectMetadata(projectName);
// const projectRoot = path.join(
// context.workspaceRoot,
// (projectMetadata.root as string | undefined) ?? '',
// );
// Users can specify a different base html file e.g. "src/home.html"
const indexFile = getIndexOutputFile(browserOptions);
// const { styles: normalizedStylesOptimization } = normalizeOptimization(
// browserOptions.optimization,
// );
const { baseOutputPath = '' } = serverResult;
const worker = new Piscina({
filename: path.join(__dirname, 'worker.js'),
name: 'render',
maxThreads: numProcesses,
});
try {
// We need to render the routes for each locale from the browser output.
for (const outputPath of browserResult.outputPaths) {
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
const serverBundlePath = path.join(baseOutputPath, localeDirectory, 'main.js');
if (!fs.existsSync(serverBundlePath)) {
throw new Error(`Could not find the main bundle: ${serverBundlePath}`);
}
const spinner = ora(`Prerendering ${routes.length} route(s) to ${outputPath}...`).start();
try {
const results = (await Promise.all(
routes.map((route) => {
const options: RenderOptions = {
indexFile,
deployUrl: browserOptions.deployUrl || '',
inlineCriticalCss: true,
minifyCss: true,
outputPath,
route,
serverBundlePath,
};
return worker.run(options, { name: 'render' });
}),
)) as RenderResult[];
let numErrors = 0;
for (const { errors, warnings } of results) {
spinner.stop();
errors?.forEach((e) => context.logger.error(e));
warnings?.forEach((e) => context.logger.warn(e));
spinner.start();
numErrors += errors?.length ?? 0;
}
if (numErrors > 0) {
throw Error(`Rendering failed with ${numErrors} worker errors.`);
}
} catch (error) {
spinner.fail(`Prerendering routes to ${outputPath} failed.`);
return { success: false, error: error.message };
}
spinner.succeed(`Prerendering routes to ${outputPath} complete.`);
// if (browserOptions.serviceWorker) {
// spinner.start('Generating service worker...');
// try {
// await augmentAppWithServiceWorker(
// projectRoot,
// context.workspaceRoot,
// outputPath,
// browserOptions.baseHref || '/',
// browserOptions.ngswConfigPath,
// );
// } catch (error) {
// spinner.fail('Service worker generation failed.');
// return { success: false, error: error.message };
// }
// spinner.succeed('Service worker generation complete.');
// }
}
} finally {
void worker.destroy();
}
return browserResult;
}
/**
* Builds the browser and server, then renders each route in options.routes
* and writes them to prerender/<route>/index.html for each output path in
* the browser result.
*/
export async function execute(
options: PrerenderBuilderOptions,
context: BuilderContext,
): Promise<PrerenderBuilderOutput> {
const browserTarget = targetFromTargetString(options.browserTarget);
const browserOptions = (await context.getTargetOptions(
browserTarget,
)) as any;
// const tsConfigPath =
// typeof browserOptions.tsConfig === 'string' ? browserOptions.tsConfig : undefined;
const routes = await getRoutes(options);
if (!routes.length) {
throw new Error(`Could not find any routes to prerender.`);
}
// const result = await _scheduleBuilds(options, context);
// const { success, error, browserResult, serverResult } = result;
// if (!success || !browserResult || !serverResult) {
// return { success, error } as BuilderOutput;
// }
const outputPath = path.resolve(context.workspaceRoot, browserOptions.outputPath );
const browserResult = {
outputPaths: [outputPath],
baseOutputPath: outputPath,
outputPath: outputPath,
success: true
}
const serverTarget = targetFromTargetString(options.serverTarget);
const serverOptions = (await context.getTargetOptions(
serverTarget,
)) as any;
const serverPath = path.resolve(context.workspaceRoot, serverOptions.outputPath);
const serverResult = {
outputPaths: [serverPath],
baseOutputPath: serverPath,
outputPath: serverPath,
success: true
}
return _renderUniversal(
routes,
context,
browserResult,
serverResult,
browserOptions,
options.numProcesses,
);
}
export default createBuilder(execute);
import { BuilderOutput } from '@angular-devkit/architect';
import { json } from '@angular-devkit/core';
import { Schema } from './schema';
export type PrerenderBuilderOptions = Schema & json.JsonObject;
export type PrerenderBuilderOutput = BuilderOutput;
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Prerender Target",
"type": "object",
"properties": {
"browserTarget": {
"type": "string",
"description": "Target to build.",
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
},
"serverTarget": {
"type": "string",
"description": "Server target to use for prerendering the app.",
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
},
"routesFile": {
"type": "string",
"description": "The path to a file containing routes separated by newlines."
},
"routes": {
"type": "array",
"description": "The routes to render.",
"items": {
"minItems": 1,
"type": "string",
"uniqueItems": true
},
"default": []
},
"guessRoutes": {
"type": "boolean",
"description": "Whether or not the builder should extract routes and guess which paths to render.",
"default": true
},
"numProcesses": {
"type": "number",
"description": "The number of cpus to use. Defaults to all but one.",
"minimum": 1
}
},
"required": ["browserTarget", "serverTarget"],
"anyOf": [{ "required": ["routes"] }, { "required": ["routesFile"] }],
"additionalProperties": false
}
export interface Schema {
/**
* Target to build.
*/
browserTarget: string;
/**
* Whether or not the builder should extract routes and guess which paths to render.
*/
guessRoutes?: boolean;
/**
* The number of cpus to use. Defaults to all but one.
*/
numProcesses?: number;
/**
* The routes to render.
*/
routes?: string[];
/**
* The path to a file containing routes separated by newlines.
*/
routesFile?: string;
/**
* Server target to use for prerendering the app.
*/
serverTarget: string;
}
import * as fs from 'fs';
import * as path from 'path';
import { URL } from 'url';
export interface RenderOptions {
indexFile: string;
deployUrl: string;
inlineCriticalCss: boolean;
minifyCss: boolean;
outputPath: string;
serverBundlePath: string;
route: string;
}
export interface RenderResult {
errors?: string[];
warnings?: string[];
}
async function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
return new Function('modulePath', `return import(modulePath);`)(modulePath);
}
// add all global references if you have any, temporarily
global.window = undefined;
/**
* Renders each route in routes and writes them to <outputPath>/<route>/index.html.
*/
export async function render({
indexFile,
deployUrl,
minifyCss,
outputPath,
serverBundlePath,
route,
inlineCriticalCss,
}: RenderOptions): Promise<RenderResult> {
const result = {} as RenderResult;
// the client index file
const browserIndexOutputPath = path.join(outputPath, indexFile);
// the route
const outputFolderPath = path.join(outputPath, route);
// route /index.html
const outputIndexPath = path.join(outputFolderPath, 'index.html');
// now get those out of the bundle, notice the dynamic import
const { renderModule, AppServerModule } = await import( serverBundlePath);
// this gets the index.original if it is found
const indexBaseName = fs.existsSync(path.join(outputPath, 'index.original.html'))
? 'index.original.html'
: indexFile;
const browserIndexInputPath = path.join(outputPath, indexBaseName);
// read and replace
let indexHtml = await fs.promises.readFile(browserIndexInputPath, 'utf8');
// replace with a silly statement
indexHtml = indexHtml.replace(
'</html>',
'<!-- This page was prerendered with Angular Universal -->\n</html>',
);
// change critical css
if (inlineCriticalCss) {
// Workaround for https://github.com/GoogleChromeLabs/critters/issues/64
indexHtml = indexHtml.replace(
/ media="print" onload="this\.media='all'"><noscript><link .+?><\/noscript>/g,
'>',
);
}
// now pass thru renderModule
let html = await renderModule(AppServerModule, {
document: indexHtml,
url: route,
});
// this is scary business
if (inlineCriticalCss) {
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,
});
result.errors = errors;
result.warnings = warnings;
html = content;
}
// This case happens when we are prerendering "/".
if (browserIndexOutputPath === outputIndexPath) {
// save index into index.original as backup
const browserIndexOutputPathOriginal = path.join(outputPath, 'index.original.html');
fs.renameSync(browserIndexOutputPath, browserIndexOutputPathOriginal);
}
// create dir if needed, then write file
fs.mkdirSync(outputFolderPath, { recursive: true });
fs.writeFileSync(outputIndexPath, html);
return result;
}
@ayyash
Copy link
Author

ayyash commented Sep 30, 2022

I copied over from https://github.com/angular/universal/blob/main/modules/builders/src/prerender/index.ts
Removed the part that creates the build and tried to pass outputPath from target options (targetFromTargetString)
The tsconfig assumes the following:

"module": "CommonJS",
"esModuleInterop": true,

This works if you set global.window in your worker file, in case we have global javascript props (which we do in case we want to replace the i18n package with our own external embedded javascript).
Read about replacing i18n on Sekrab Garage
Read about prerendering in a different way

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment