diff --git a/modules/builders/BUILD.bazel b/modules/builders/BUILD.bazel index b2dffd72c..874a501e4 100644 --- a/modules/builders/BUILD.bazel +++ b/modules/builders/BUILD.bazel @@ -1,5 +1,5 @@ load("@npm_bazel_typescript//:index.bzl", "ts_config") -load("//tools:defaults.bzl", "npm_package", "ts_library") +load("//tools:defaults.bzl", "jasmine_node_test", "ng_test_library", "npm_package", "ts_library") ts_config( name = "bazel-tsconfig-build", @@ -44,3 +44,20 @@ npm_package( tags = ["release"], deps = [":builders"], ) + +ng_test_library( + name = "unit_test_lib", + srcs = glob([ + "**/*.spec.ts", + ]), + deps = [ + ":builders", + "@npm//@angular-devkit/architect", + "@npm//@angular-devkit/core", + ], +) + +jasmine_node_test( + name = "unit_test", + srcs = [":unit_test_lib"], +) diff --git a/modules/builders/builders.json b/modules/builders/builders.json index 7764416f3..7176d8fba 100644 --- a/modules/builders/builders.json +++ b/modules/builders/builders.json @@ -5,6 +5,11 @@ "implementation": "./src/ssr-dev-server", "schema": "./src/ssr-dev-server/schema.json", "description": "Serve a universal application." + }, + "prerender": { + "implementation": "./src/prerender/index", + "schema": "./src/prerender/schema.json", + "description": "Perform build-time prerendering of chosen routes." } } } diff --git a/modules/builders/src/prerender/index.spec.ts b/modules/builders/src/prerender/index.spec.ts new file mode 100644 index 000000000..05e879220 --- /dev/null +++ b/modules/builders/src/prerender/index.spec.ts @@ -0,0 +1,348 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as PrerenderModule from './index'; +import { Schema } from './schema'; + +import { BuilderContext, BuilderRun } from '@angular-devkit/architect'; +import { JsonObject, logging } from '@angular-devkit/core'; + +import * as fs from 'fs'; + +const emptyFn = () => {}; + +describe('Prerender Builder', () => { + const PROJECT_NAME = 'pokemon'; + let context: BuilderContext; + let browserResult: PrerenderModule.BuilderOutputWithPaths; + let serverResult: PrerenderModule.BuilderOutputWithPaths; + let options: JsonObject & Schema; + + beforeEach(() => { + options = { + browserTarget: `${PROJECT_NAME}:build`, + serverTarget: `${PROJECT_NAME}:server`, + routes: ['/'], + }; + browserResult = { + success: true, + baseOutputPath: '', + outputPaths: ['dist/browser'], + } as PrerenderModule.BuilderOutputWithPaths; + serverResult = { + success: true, + baseOutputPath: '', + outputPaths: ['dist/server'], + } as PrerenderModule.BuilderOutputWithPaths; + context = createMockBuilderContext({ + logger: new logging.NullLogger(), + workspaceRoot: '', + }); + }); + + describe('#_prerender', () => { + let scheduleTargetSpy: jasmine.Spy; + let renderUniversalSpy: jasmine.Spy; + let browserRun: BuilderRun; + let serverRun: BuilderRun; + + beforeEach(() => { + browserRun = createMockBuilderRun({result: browserResult}); + serverRun = createMockBuilderRun({result: serverResult}); + spyOn(context, 'scheduleTarget') + .and.returnValues(Promise.resolve(browserRun), Promise.resolve(serverRun)); + scheduleTargetSpy = context.scheduleTarget as jasmine.Spy; + spyOn(PrerenderModule, '_renderUniversal').and.callFake( + (_options: any, _context: any, _browserResult: any, _serverResult: any) => _browserResult + ); + renderUniversalSpy = PrerenderModule._renderUniversal as jasmine.Spy; + }); + + it('should schedule a build and server target', async () => { + await PrerenderModule._prerender(options, context); + expect(scheduleTargetSpy.calls.allArgs()).toEqual([ + [{project: PROJECT_NAME, target: 'build'}, {watch: false, serviceWorker: false}], + [{project: PROJECT_NAME, target: 'server'}, {watch: false}], + ]); + }); + + it('should call stop on the build and server run targets', async () => { + spyOn(browserRun, 'stop'); + spyOn(serverRun, 'stop'); + await PrerenderModule._prerender(options, context); + expect(browserRun.stop).toHaveBeenCalled(); + expect(serverRun.stop).toHaveBeenCalled(); + }); + + it('should call _renderUniversal', async () => { + const result = await PrerenderModule._prerender(options, context); + expect(result).toBe(await browserRun.result); + expect(renderUniversalSpy.calls.allArgs()).toEqual([ + [options, context, browserResult, serverResult], + ]); + }); + + it('should early exit if the browser build fails', async () => { + const failedBrowserRun = createMockBuilderRun({ + result: { + success: false, + baseOutputPath: '', + outputPaths: ['dist/browser'], + } + }); + scheduleTargetSpy.and.returnValues( + Promise.resolve(failedBrowserRun), + Promise.resolve(serverRun), + ); + const result = await PrerenderModule._prerender(options, context); + expect(result).toBe(await failedBrowserRun.result); + expect(renderUniversalSpy).not.toHaveBeenCalled(); + }); + + it('should early exit if the browser build has no base output path', async () => { + const failedBrowserRun = createMockBuilderRun({ + result: { + success: true, + baseOutputPath: undefined, + outputPaths: ['dist/browser'], + } + }); + scheduleTargetSpy.and.returnValues( + Promise.resolve(failedBrowserRun), + Promise.resolve(serverRun), + ); + const result = await PrerenderModule._prerender(options, context); + expect(result).toBe(await failedBrowserRun.result); + expect(renderUniversalSpy).not.toHaveBeenCalled(); + }); + + it('should early exit if the server build fails', async () => { + const failedServerRun = createMockBuilderRun({ + result: { + success: false, + baseOutputPath: '', + outputPaths: ['dist/server'], + } + }); + scheduleTargetSpy.and.returnValues( + Promise.resolve(browserRun), + Promise.resolve(failedServerRun), + ); + const result = await PrerenderModule._prerender(options, context); + expect(result).toBe(await failedServerRun.result); + expect(renderUniversalSpy).not.toHaveBeenCalled(); + }); + + it('should catch errors thrown by _renderUniversal', async () => { + const errmsg = 'Test _renderUniversal error.'; + const expected = {success: false, error: errmsg}; + renderUniversalSpy.and.callFake(() => { + throw Error(errmsg); + }); + await expectAsync(PrerenderModule._prerender(options, context)).toBeResolvedTo(expected); + }); + + it('should throw if no routes are given', async () => { + options.routes = []; + const expectedError = new Error('No routes found. options.routes must contain at least one route to render.'); + await expectAsync( + PrerenderModule._prerender(options, context) + ).toBeRejectedWith(expectedError); + }); + }); + + describe('#_renderUniversal', () => { + const INITIAL_HTML = ''; + const RENDERED_HTML = '[Rendered Content]'; + let renderModuleFnSpy: jasmine.Spy; + let readFileSyncSpy: jasmine.Spy; + let mkdirSyncSpy: jasmine.Spy; + let writeFileSyncSpy: jasmine.Spy; + let getServerModuleBundleSpy: jasmine.Spy; + + beforeEach(() => { + renderModuleFnSpy = jasmine.createSpy('renderModuleFactory') + .and.returnValue(Promise.resolve(RENDERED_HTML)); + spyOn(PrerenderModule, '_getServerModuleBundle').and.returnValue(Promise.resolve({ + renderModuleFn: renderModuleFnSpy, + AppServerModuleDef: emptyFn, + })); + getServerModuleBundleSpy = PrerenderModule._getServerModuleBundle as jasmine.Spy; + spyOn(fs, 'readFileSync').and.callFake(() => '' as any); + readFileSyncSpy = fs.readFileSync as jasmine.Spy; + spyOn(fs, 'mkdirSync').and.callFake(emptyFn); + mkdirSyncSpy = fs.mkdirSync as jasmine.Spy; + spyOn(fs, 'writeFileSync').and.callFake(emptyFn); + writeFileSyncSpy = fs.writeFileSync as jasmine.Spy; + }); + + it('should use dist/browser/index.html as the base html', async () => { + await PrerenderModule._renderUniversal(options, context, browserResult, serverResult); + expect(readFileSyncSpy.calls.allArgs()).toEqual([ + ['dist/browser/index.html', 'utf8'], + ]); + }); + + it('should render each route', async () => { + getServerModuleBundleSpy.and.returnValue(Promise.resolve({ + renderModuleFn: renderModuleFnSpy, + AppServerModuleDef: emptyFn, + })); + options.routes = ['route1', 'route2', 'route3']; + await PrerenderModule._renderUniversal(options, context, browserResult, serverResult); + expect(renderModuleFnSpy.calls.allArgs()).toEqual([ + [emptyFn, {document: INITIAL_HTML, url: 'route1'}], + [emptyFn, {document: INITIAL_HTML, url: 'route2'}], + [emptyFn, {document: INITIAL_HTML, url: 'route3'}], + ]); + }); + + it('should create a new directory for each route', async () => { + options.routes = ['route1', 'route2']; + await PrerenderModule._renderUniversal(options, context, browserResult, serverResult); + expect(mkdirSyncSpy.calls.allArgs()).toEqual([ + ['dist/browser/route1'], + ['dist/browser/route2'], + ]); + }); + + it('should write to "index/index.html" for route "/"', async () => { + await PrerenderModule._renderUniversal(options, context, browserResult, serverResult); + expect(mkdirSyncSpy.calls.allArgs()).toEqual([ + ['dist/browser/index'], + ]); + expect(writeFileSyncSpy.calls.allArgs()).toEqual([ + ['dist/browser/index/index.html', RENDERED_HTML], + ]); + }); + + it('should try to write the rendered html for each route to "route/index.html"', async () => { + options.routes = ['route1', 'route2']; + await PrerenderModule._renderUniversal(options, context, browserResult, serverResult); + expect(writeFileSyncSpy.calls.allArgs()).toEqual([ + ['dist/browser/route1/index.html', RENDERED_HTML], + ['dist/browser/route2/index.html', RENDERED_HTML], + ]); + }); + + it('should catch errors thrown when writing the rendered html', async () => { + mkdirSyncSpy.and.callFake(() => { + throw new Error('Test mkdirSync error.'); + }); + await expectAsync( + PrerenderModule._renderUniversal( + options, + context, + browserResult, + serverResult + ) + ).not.toBeRejected(); + expect(mkdirSyncSpy).toHaveBeenCalled(); + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + }); + }); + + describe('#_getServerModuleBundle', () => { + const browserDirectory = 'dist/server'; + let importSpy: jasmine.Spy; + let existsSyncSpy: jasmine.Spy; + + beforeEach(() => { + spyOn(PrerenderModule, '_importWrapper').and.returnValue(Promise.resolve({ + renderModule: emptyFn, + AppServerModule: emptyFn, + })); + importSpy = PrerenderModule._importWrapper as jasmine.Spy; + spyOn(fs, 'existsSync').and.returnValue(true); + existsSyncSpy = fs.existsSync as jasmine.Spy; + }); + + it('return a serverModuleBundle', async () => { + await expectAsync( + PrerenderModule._getServerModuleBundle( + serverResult, + browserDirectory + ) + ).toBeResolvedTo({ + renderModuleFn: emptyFn, + AppServerModuleDef: emptyFn, + }); + }); + + it('return a serverModuleBundle from factories', async () => { + importSpy.and.returnValue(Promise.resolve({ + renderModuleFactory: emptyFn, + AppServerModuleNgFactory: emptyFn, + })); + await expectAsync( + PrerenderModule._getServerModuleBundle( + serverResult, + browserDirectory + ) + ).toBeResolvedTo({ + renderModuleFn: emptyFn, + AppServerModuleDef: emptyFn, + }); + }); + + it('should throw if the server bundle file does not exist', async () => { + existsSyncSpy.and.returnValue(false); + const expectedError = new Error(`Could not find the main bundle: dist/server/main.js`); + await expectAsync( + PrerenderModule._getServerModuleBundle( + serverResult, + browserDirectory + ) + ).toBeRejectedWith(expectedError); + }); + + it('should throw if no serverModuleBundle is defined', async () => { + importSpy.and.returnValue(Promise.resolve({})); + const expectedError = new Error('renderModule method and/or AppServerModule were not exported from: dist/server/main.js.'); + await expectAsync( + PrerenderModule._getServerModuleBundle(serverResult, browserDirectory) + ).toBeRejectedWith(expectedError); + }); + }); +}); + +function createMockBuilderContext(overrides?: object) { + const context = { + id: null, + builder: null, + logger: null, + workspaceRoot: null, + currentDirectory: null, + target: null, + analytics: null, + scheduleTarget: emptyFn, + scheduleBuilder: emptyFn, + getTargetOptions: emptyFn, + getProjectMetadata: emptyFn, + getBuilderNameForTarget: emptyFn, + validateOptions: emptyFn, + reportRunning: emptyFn, + reportStatus: emptyFn, + reportProgress: emptyFn, + addTeardown: emptyFn, + ...overrides, + }; + return context as unknown as BuilderContext; +} + +function createMockBuilderRun(overrides?: object) { + const run = { + id: null, + info: null, + result: null, + output: null, + progress: null, + stop: emptyFn, + ...overrides, + }; + return run as unknown as BuilderRun; +} diff --git a/modules/builders/src/prerender/index.ts b/modules/builders/src/prerender/index.ts new file mode 100644 index 000000000..d0df225ef --- /dev/null +++ b/modules/builders/src/prerender/index.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { BuilderOutput, createBuilder, BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import { JsonObject } from '@angular-devkit/core'; +import { Schema as BuildWebpackPrerenderSchema } from './schema'; + +import { Buffer } from 'buffer'; +import * as fs from 'fs'; +import * as path from 'path'; + +export type BuilderOutputWithPaths = JsonObject & BuilderOutput & { + baseOutputPath: string; + outputPaths: string[]; + outputPath: string; +}; + +/** + * A wrapper for import to make unit tests possible. + */ +export function _importWrapper(importPath: string) { + return import(importPath); +} + +/** + * Renders each route in options.routes and writes them to + * /index.html for each output path in the browser result. + */ +export async function _renderUniversal( + options: BuildWebpackPrerenderSchema, + context: BuilderContext, + browserResult: BuilderOutputWithPaths, + serverResult: BuilderOutputWithPaths, +): Promise { + // 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 browserIndexOutputPath = path.join(outputPath, 'index.html'); + const indexHtml = fs.readFileSync(browserIndexOutputPath, 'utf8'); + const { AppServerModuleDef, renderModuleFn } = + await exports._getServerModuleBundle(serverResult, localeDirectory); + + context.logger.info(`\nPrerendering ${options.routes.length} route(s) to ${outputPath}`); + + // Render each route and write them to /index.html. + for (const route of options.routes) { + const renderOpts = { + document: indexHtml, + url: route, + }; + const html = await renderModuleFn(AppServerModuleDef, renderOpts); + + const outputFolderName = route === '/' ? 'index' : route; + const outputFolderPath = path.join(outputPath, outputFolderName); + const outputIndexPath = path.join(outputFolderPath, 'index.html'); + + // There will never conflicting output folders + // because items in options.routes must be unique. + try { + fs.mkdirSync(outputFolderPath); + fs.writeFileSync(outputIndexPath, html); + const bytes = Buffer.byteLength(html).toFixed(0); + context.logger.info( + `CREATE ${outputIndexPath} (${bytes} bytes)` + ); + } catch (e) { + context.logger.error(`Unable to render ${outputIndexPath}`); + } + } + } + return browserResult; +} + +/** + * If the app module bundle path is not specified in options.appModuleBundle, + * this method searches for what is usually the app module bundle file and + * returns its server module bundle. + * + * Throws if no app module bundle is found. + */ +export async function _getServerModuleBundle( + serverResult: BuilderOutputWithPaths, + browserLocaleDirectory: string, +) { + const { baseOutputPath = '' } = serverResult; + const serverBundlePath = path.join(baseOutputPath, browserLocaleDirectory, 'main.js'); + + if (!fs.existsSync(serverBundlePath)) { + throw new Error(`Could not find the main bundle: ${serverBundlePath}`); + } + + const { + AppServerModule, + AppServerModuleNgFactory, + renderModule, + renderModuleFactory, + } = await exports._importWrapper(serverBundlePath); + + if (renderModuleFactory && AppServerModuleNgFactory) { + // Happens when in ViewEngine mode. + return { + renderModuleFn: renderModuleFactory, + AppServerModuleDef: AppServerModuleNgFactory, + }; + } + + if (renderModule && AppServerModule) { + // Happens when in Ivy mode. + return { + renderModuleFn: renderModule, + AppServerModuleDef: AppServerModule, + }; + } + throw new Error(`renderModule method and/or AppServerModule were not exported from: ${serverBundlePath}.`); +} + +/** + * Builds the browser and server, then renders each route in options.routes + * and writes them to prerender//index.html for each output path in + * the browser result. + */ +export async function _prerender( + options: JsonObject & BuildWebpackPrerenderSchema, + context: BuilderContext +): Promise { + if (!options.routes || options.routes.length === 0) { + throw new Error('No routes found. options.routes must contain at least one route to render.'); + } + 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 BuilderOutputWithPaths, + serverTargetRun.result as unknown as BuilderOutputWithPaths, + ]); + + if (browserResult.success === false || browserResult.baseOutputPath === undefined) { + return browserResult; + } + if (serverResult.success === false) { + return serverResult; + } + + return await exports._renderUniversal(options, context, browserResult, serverResult); + } catch (e) { + return { success: false, error: e.message }; + } finally { + await Promise.all([browserTargetRun.stop(), serverTargetRun.stop()]); + } +} + +export default createBuilder(_prerender); diff --git a/modules/builders/src/prerender/schema.json b/modules/builders/src/prerender/schema.json new file mode 100644 index 000000000..a0b6804db --- /dev/null +++ b/modules/builders/src/prerender/schema.json @@ -0,0 +1,32 @@ +{ + "$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]+)?$" + }, + "routes": { + "type": "array", + "description": "The routes to render.", + "items": { + "type": "string", + "uniqueItems": true + }, + "default": [] + } + }, + "required": [ + "browserTarget", + "serverTarget", + "routes" + ], + "additionalProperties": false +} diff --git a/modules/builders/src/prerender/schema.ts b/modules/builders/src/prerender/schema.ts new file mode 100644 index 000000000..464f3cea8 --- /dev/null +++ b/modules/builders/src/prerender/schema.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + + export interface Schema { + /** + * Script that exports the Server AppModule to render. This should be the main JavaScript + * outputted by the server target. By default we will resolve the outputPath of the + * serverTarget and find a bundle named 'main' in it (whether or not there's a hash tag). + */ + appModuleBundle?: string; + /** + * Target to build. + */ + browserTarget: string; + /** + * The routes to render. + */ + routes: string[]; + /** + * Server target to use for prerendering the app. + */ + serverTarget: string; +}