Last active
September 30, 2022 12:09
-
-
Save ayyash/2775f0c24ef09f0036095f2c0d0a3c4a to your computer and use it in GitHub Desktop.
Skimmed down version of nguniversal prerender builder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"$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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
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