From ac4f24e04085649e2569930f02366bca5d968b33 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 4 Dec 2019 14:22:54 -0800 Subject: [PATCH 1/6] feat(builders): implement prerender --- modules/builders/BUILD.bazel | 19 +- modules/builders/builders.json | 5 + modules/builders/src/index.ts | 1 + modules/builders/src/prerender/index.spec.ts | 389 +++++++++++++++++++ modules/builders/src/prerender/index.ts | 189 +++++++++ modules/builders/src/prerender/schema.json | 34 ++ modules/builders/src/prerender/schema.ts | 28 ++ 7 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 modules/builders/src/prerender/index.spec.ts create mode 100644 modules/builders/src/prerender/index.ts create mode 100644 modules/builders/src/prerender/schema.json create mode 100644 modules/builders/src/prerender/schema.ts 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..e99a259e6 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": "Prerenders static files." } } } diff --git a/modules/builders/src/index.ts b/modules/builders/src/index.ts index 731f52bd7..1651baa40 100644 --- a/modules/builders/src/index.ts +++ b/modules/builders/src/index.ts @@ -7,3 +7,4 @@ */ export * from './ssr-dev-server/index'; +export * from './prerender/index'; diff --git a/modules/builders/src/prerender/index.spec.ts b/modules/builders/src/prerender/index.spec.ts new file mode 100644 index 000000000..13178413c --- /dev/null +++ b/modules/builders/src/prerender/index.spec.ts @@ -0,0 +1,389 @@ +/** + * @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 = { + appModuleBundle: 'dist/browser/main.js', + 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); + }); + }); + 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; + // @ts-ignore + spyOn(fs, 'readFileSync').and.callFake(() => ''); + readFileSyncSpy = fs.readFileSync as jasmine.Spy; + // @ts-ignore + spyOn(fs, 'mkdirSync').and.callFake(emptyFn); + mkdirSyncSpy = fs.mkdirSync as jasmine.Spy; + // @ts-ignore + 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 try to 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/browser'; + let importSpy: jasmine.Spy; + + beforeEach(() => { + spyOn(PrerenderModule, '_importWrapper').and.returnValue(Promise.resolve({ + renderModule: emptyFn, + AppServerModule: emptyFn, + })); + importSpy = PrerenderModule._importWrapper as jasmine.Spy; + }); + + it('return a serverModuleBundle', async () => { + await expectAsync( + PrerenderModule._getServerModuleBundle( + options, + context, + 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( + options, + context, + serverResult, + browserDirectory + ) + ).toBeResolvedTo({ + renderModuleFn: emptyFn, + AppServerModuleDef: emptyFn, + }); + }); + + it('should search for a bundle if options.appModuleBundle is not defined', async () => { + // @ts-ignore + spyOn(fs, 'readdirSync').and.returnValue(['main.js']); + spyOn(fs, 'existsSync').and.returnValue(true); + delete options.appModuleBundle; + await expectAsync( + PrerenderModule._getServerModuleBundle( + options, + context, + serverResult, + browserDirectory + ) + ).toBeResolvedTo({ + renderModuleFn: emptyFn, + AppServerModuleDef: emptyFn, + }); + expect(importSpy.calls.allArgs()).toEqual([ + ['dist/browser/main.js'], + ]); + }); + + it('should throw if outputPath does not exist', async () => { + spyOn(fs, 'existsSync').and.returnValue(false); + delete options.appModuleBundle; + const expectedError = new Error(`Could not find server output directory: dist/browser.`); + await expectAsync( + PrerenderModule._getServerModuleBundle( + options, + context, + serverResult, + browserDirectory + ) + ).toBeRejectedWith(expectedError); + }); + + it('should throw if a module bundle cannot be found', async () => { + // @ts-ignore + spyOn(fs, 'readdirSync').and.returnValue(['server.js']); + spyOn(fs, 'existsSync').and.returnValue(true); + delete options.appModuleBundle; + const expectedError = new Error('Could not find the main bundle.'); + await expectAsync( + PrerenderModule._getServerModuleBundle( + options, + context, + 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/browser/main.js.`); + await expectAsync( + PrerenderModule._getServerModuleBundle( + options, + context, + 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..1b00f4624 --- /dev/null +++ b/modules/builders/src/prerender/index.ts @@ -0,0 +1,189 @@ +/** + * @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. + * + * @param serverBundlePath + */ +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. + * + * @param options + * @param context + * @param browserResult + * @param serverResult + */ +export async function _renderUniversal( + options: BuildWebpackPrerenderSchema, + context: BuilderContext, + browserResult: BuilderOutputWithPaths, + serverResult: BuilderOutputWithPaths, +): Promise { + 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(options, context, serverResult, localeDirectory); + + context.logger.info(`\nPrerendering ${options.routes!.length} route(s) to ${outputPath}`); + 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 ${outputFolderName}/index.html (${bytes} bytes)` + ); + } catch (e) { + context.logger.error(`unable to render ${route}/index.html`); + } + } + } + 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. + * + * @param options + * @param context + * @param serverResult + * @param browserLocaleDirectory + */ +export async function _getServerModuleBundle( + options: BuildWebpackPrerenderSchema, + context: BuilderContext, + serverResult: BuilderOutputWithPaths, + browserLocaleDirectory: string, +) { + let serverBundlePath; + if (options.appModuleBundle) { + serverBundlePath = path.join(context.workspaceRoot, options.appModuleBundle); + } else { + const { baseOutputPath = '' } = serverResult; + const outputPath = path.join(baseOutputPath, browserLocaleDirectory); + + if (!fs.existsSync(outputPath)) { + throw new Error(`Could not find server output directory: ${outputPath}.`); + } + + const files = fs.readdirSync(outputPath, 'utf8'); + const re = /^main\.(?:[a-zA-Z0-9]{20}\.)?(?:bundle\.)?js$/; + const maybeMain = files.filter(x => re.test(x))[0]; + + if (!maybeMain) { + throw new Error('Could not find the main bundle.'); + } else { + serverBundlePath = path.join(outputPath, maybeMain); + } + } + + const { + AppServerModule, + AppServerModuleNgFactory, + renderModule, + renderModuleFactory, + } = await exports._importWrapper(serverBundlePath); + + if (renderModuleFactory && AppServerModuleNgFactory) { + return { + renderModuleFn: renderModuleFactory, + AppServerModuleDef: AppServerModuleNgFactory, + }; + } + if (renderModule && AppServerModule) { + 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. + * + * @param options + * @param context + */ +export async function _prerender( + options: JsonObject & BuildWebpackPrerenderSchema, + context: BuilderContext +): Promise { + 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..3044b3fed --- /dev/null +++ b/modules/builders/src/prerender/schema.json @@ -0,0 +1,34 @@ +{ + "$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]+)?$" + }, + "appModuleBundle": { + "type": "string", + "description": "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)." + }, + "routes": { + "type": "array", + "description": "The routes to render.", + "items": { + "type": "string", + "uniqueItems": true + }, + "default": ["/"] + } + }, + "required": [ + "browserTarget", + "serverTarget" + ] +} diff --git a/modules/builders/src/prerender/schema.ts b/modules/builders/src/prerender/schema.ts new file mode 100644 index 000000000..6ff54e5ba --- /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; +} From 676361a2f3a04c0709fdfb63067368e3d53fc0b1 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 4 Dec 2019 15:41:24 -0800 Subject: [PATCH 2/6] made changes requested by @vikerman --- modules/builders/builders.json | 2 +- modules/builders/src/prerender/index.spec.ts | 24 ++++++++++------- modules/builders/src/prerender/index.ts | 28 ++++++++------------ modules/builders/src/prerender/schema.json | 5 ++-- modules/builders/src/prerender/schema.ts | 2 +- 5 files changed, 30 insertions(+), 31 deletions(-) diff --git a/modules/builders/builders.json b/modules/builders/builders.json index e99a259e6..7176d8fba 100644 --- a/modules/builders/builders.json +++ b/modules/builders/builders.json @@ -9,7 +9,7 @@ "prerender": { "implementation": "./src/prerender/index", "schema": "./src/prerender/schema.json", - "description": "Prerenders static files." + "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 index 13178413c..f5361c1e3 100644 --- a/modules/builders/src/prerender/index.spec.ts +++ b/modules/builders/src/prerender/index.spec.ts @@ -146,7 +146,16 @@ describe('Prerender Builder', () => { }); 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]'; @@ -164,13 +173,10 @@ describe('Prerender Builder', () => { AppServerModuleDef: emptyFn, })); getServerModuleBundleSpy = PrerenderModule._getServerModuleBundle as jasmine.Spy; - // @ts-ignore - spyOn(fs, 'readFileSync').and.callFake(() => ''); + spyOn(fs, 'readFileSync').and.callFake(() => '' as any); readFileSyncSpy = fs.readFileSync as jasmine.Spy; - // @ts-ignore spyOn(fs, 'mkdirSync').and.callFake(emptyFn); mkdirSyncSpy = fs.mkdirSync as jasmine.Spy; - // @ts-ignore spyOn(fs, 'writeFileSync').and.callFake(emptyFn); writeFileSyncSpy = fs.writeFileSync as jasmine.Spy; }); @@ -286,8 +292,7 @@ describe('Prerender Builder', () => { }); it('should search for a bundle if options.appModuleBundle is not defined', async () => { - // @ts-ignore - spyOn(fs, 'readdirSync').and.returnValue(['main.js']); + spyOn(fs, 'readdirSync').and.returnValue(['main.js'] as any); spyOn(fs, 'existsSync').and.returnValue(true); delete options.appModuleBundle; await expectAsync( @@ -309,7 +314,7 @@ describe('Prerender Builder', () => { it('should throw if outputPath does not exist', async () => { spyOn(fs, 'existsSync').and.returnValue(false); delete options.appModuleBundle; - const expectedError = new Error(`Could not find server output directory: dist/browser.`); + const expectedError = new Error('Could not find server output directory: dist/browser.'); await expectAsync( PrerenderModule._getServerModuleBundle( options, @@ -321,8 +326,7 @@ describe('Prerender Builder', () => { }); it('should throw if a module bundle cannot be found', async () => { - // @ts-ignore - spyOn(fs, 'readdirSync').and.returnValue(['server.js']); + spyOn(fs, 'readdirSync').and.returnValue(['server.js' as any]); spyOn(fs, 'existsSync').and.returnValue(true); delete options.appModuleBundle; const expectedError = new Error('Could not find the main bundle.'); @@ -338,7 +342,7 @@ describe('Prerender Builder', () => { 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/browser/main.js.`); + const expectedError = new Error('renderModule method and/or AppServerModule were not exported from: dist/browser/main.js.'); await expectAsync( PrerenderModule._getServerModuleBundle( options, diff --git a/modules/builders/src/prerender/index.ts b/modules/builders/src/prerender/index.ts index 1b00f4624..430f07b94 100644 --- a/modules/builders/src/prerender/index.ts +++ b/modules/builders/src/prerender/index.ts @@ -22,8 +22,6 @@ export type BuilderOutputWithPaths = JsonObject & BuilderOutput & { /** * A wrapper for import to make unit tests possible. - * - * @param serverBundlePath */ export function _importWrapper(importPath: string) { return import(importPath); @@ -32,11 +30,6 @@ export function _importWrapper(importPath: string) { /** * Renders each route in options.routes and writes them to * /index.html for each output path in the browser result. - * - * @param options - * @param context - * @param browserResult - * @param serverResult */ export async function _renderUniversal( options: BuildWebpackPrerenderSchema, @@ -44,6 +37,7 @@ export async function _renderUniversal( 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'); @@ -51,8 +45,10 @@ export async function _renderUniversal( const { AppServerModuleDef, renderModuleFn } = await exports._getServerModuleBundle(options, context, serverResult, localeDirectory); - context.logger.info(`\nPrerendering ${options.routes!.length} route(s) to ${outputPath}`); - for (const route of options.routes!) { + 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, @@ -86,11 +82,6 @@ export async function _renderUniversal( * returns its server module bundle. * * Throws if no app module bundle is found. - * - * @param options - * @param context - * @param serverResult - * @param browserLocaleDirectory */ export async function _getServerModuleBundle( options: BuildWebpackPrerenderSchema, @@ -128,12 +119,15 @@ export async function _getServerModuleBundle( } = 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, @@ -146,14 +140,14 @@ export async function _getServerModuleBundle( * 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. - * - * @param options - * @param context */ 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); diff --git a/modules/builders/src/prerender/schema.json b/modules/builders/src/prerender/schema.json index 3044b3fed..77695edfa 100644 --- a/modules/builders/src/prerender/schema.json +++ b/modules/builders/src/prerender/schema.json @@ -24,11 +24,12 @@ "type": "string", "uniqueItems": true }, - "default": ["/"] + "default": [] } }, "required": [ "browserTarget", - "serverTarget" + "serverTarget", + "routes" ] } diff --git a/modules/builders/src/prerender/schema.ts b/modules/builders/src/prerender/schema.ts index 6ff54e5ba..464f3cea8 100644 --- a/modules/builders/src/prerender/schema.ts +++ b/modules/builders/src/prerender/schema.ts @@ -20,7 +20,7 @@ /** * The routes to render. */ - routes?: string[]; + routes: string[]; /** * Server target to use for prerendering the app. */ From 65ef7110956eaf7965007a68325e106ca47292d1 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 4 Dec 2019 17:09:47 -0800 Subject: [PATCH 3/6] fixed logging bug --- modules/builders/src/prerender/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/builders/src/prerender/index.ts b/modules/builders/src/prerender/index.ts index 430f07b94..c46d7dafe 100644 --- a/modules/builders/src/prerender/index.ts +++ b/modules/builders/src/prerender/index.ts @@ -69,7 +69,7 @@ export async function _renderUniversal( `CREATE ${outputFolderName}/index.html (${bytes} bytes)` ); } catch (e) { - context.logger.error(`unable to render ${route}/index.html`); + context.logger.error(`unable to render ${outputFolderName}/index.html`); } } } From 860e590dd71b4edf08eb7cfd76a0b7089095ef89 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 5 Dec 2019 10:32:32 -0800 Subject: [PATCH 4/6] only forward default export --- modules/builders/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/builders/src/index.ts b/modules/builders/src/index.ts index 1651baa40..a53594684 100644 --- a/modules/builders/src/index.ts +++ b/modules/builders/src/index.ts @@ -7,4 +7,4 @@ */ export * from './ssr-dev-server/index'; -export * from './prerender/index'; +export { } from './prerender/index'; From c7527631be62da628917f780966ff170f89edb0c Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 5 Dec 2019 14:29:16 -0800 Subject: [PATCH 5/6] final changes requested by @alan-agius4 and @mgechev --- modules/builders/src/prerender/index.spec.ts | 65 +++----------------- modules/builders/src/prerender/index.ts | 31 +++------- modules/builders/src/prerender/schema.json | 7 +-- 3 files changed, 19 insertions(+), 84 deletions(-) diff --git a/modules/builders/src/prerender/index.spec.ts b/modules/builders/src/prerender/index.spec.ts index f5361c1e3..05e879220 100644 --- a/modules/builders/src/prerender/index.spec.ts +++ b/modules/builders/src/prerender/index.spec.ts @@ -24,7 +24,6 @@ describe('Prerender Builder', () => { beforeEach(() => { options = { - appModuleBundle: 'dist/browser/main.js', browserTarget: `${PROJECT_NAME}:build`, serverTarget: `${PROJECT_NAME}:server`, routes: ['/'], @@ -188,7 +187,7 @@ describe('Prerender Builder', () => { ]); }); - it('should try to render each route', async () => { + it('should render each route', async () => { getServerModuleBundleSpy.and.returnValue(Promise.resolve({ renderModuleFn: renderModuleFnSpy, AppServerModuleDef: emptyFn, @@ -248,8 +247,9 @@ describe('Prerender Builder', () => { }); describe('#_getServerModuleBundle', () => { - const browserDirectory = 'dist/browser'; + const browserDirectory = 'dist/server'; let importSpy: jasmine.Spy; + let existsSyncSpy: jasmine.Spy; beforeEach(() => { spyOn(PrerenderModule, '_importWrapper').and.returnValue(Promise.resolve({ @@ -257,13 +257,13 @@ describe('Prerender Builder', () => { 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( - options, - context, serverResult, browserDirectory ) @@ -280,8 +280,6 @@ describe('Prerender Builder', () => { })); await expectAsync( PrerenderModule._getServerModuleBundle( - options, - context, serverResult, browserDirectory ) @@ -291,49 +289,11 @@ describe('Prerender Builder', () => { }); }); - it('should search for a bundle if options.appModuleBundle is not defined', async () => { - spyOn(fs, 'readdirSync').and.returnValue(['main.js'] as any); - spyOn(fs, 'existsSync').and.returnValue(true); - delete options.appModuleBundle; + 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( - options, - context, - serverResult, - browserDirectory - ) - ).toBeResolvedTo({ - renderModuleFn: emptyFn, - AppServerModuleDef: emptyFn, - }); - expect(importSpy.calls.allArgs()).toEqual([ - ['dist/browser/main.js'], - ]); - }); - - it('should throw if outputPath does not exist', async () => { - spyOn(fs, 'existsSync').and.returnValue(false); - delete options.appModuleBundle; - const expectedError = new Error('Could not find server output directory: dist/browser.'); - await expectAsync( - PrerenderModule._getServerModuleBundle( - options, - context, - serverResult, - browserDirectory - ) - ).toBeRejectedWith(expectedError); - }); - - it('should throw if a module bundle cannot be found', async () => { - spyOn(fs, 'readdirSync').and.returnValue(['server.js' as any]); - spyOn(fs, 'existsSync').and.returnValue(true); - delete options.appModuleBundle; - const expectedError = new Error('Could not find the main bundle.'); - await expectAsync( - PrerenderModule._getServerModuleBundle( - options, - context, serverResult, browserDirectory ) @@ -342,14 +302,9 @@ describe('Prerender Builder', () => { 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/browser/main.js.'); + const expectedError = new Error('renderModule method and/or AppServerModule were not exported from: dist/server/main.js.'); await expectAsync( - PrerenderModule._getServerModuleBundle( - options, - context, - serverResult, - browserDirectory - ) + PrerenderModule._getServerModuleBundle(serverResult, browserDirectory) ).toBeRejectedWith(expectedError); }); }); diff --git a/modules/builders/src/prerender/index.ts b/modules/builders/src/prerender/index.ts index c46d7dafe..d0df225ef 100644 --- a/modules/builders/src/prerender/index.ts +++ b/modules/builders/src/prerender/index.ts @@ -43,7 +43,7 @@ export async function _renderUniversal( const browserIndexOutputPath = path.join(outputPath, 'index.html'); const indexHtml = fs.readFileSync(browserIndexOutputPath, 'utf8'); const { AppServerModuleDef, renderModuleFn } = - await exports._getServerModuleBundle(options, context, serverResult, localeDirectory); + await exports._getServerModuleBundle(serverResult, localeDirectory); context.logger.info(`\nPrerendering ${options.routes.length} route(s) to ${outputPath}`); @@ -66,10 +66,10 @@ export async function _renderUniversal( fs.writeFileSync(outputIndexPath, html); const bytes = Buffer.byteLength(html).toFixed(0); context.logger.info( - `CREATE ${outputFolderName}/index.html (${bytes} bytes)` + `CREATE ${outputIndexPath} (${bytes} bytes)` ); } catch (e) { - context.logger.error(`unable to render ${outputFolderName}/index.html`); + context.logger.error(`Unable to render ${outputIndexPath}`); } } } @@ -84,31 +84,14 @@ export async function _renderUniversal( * Throws if no app module bundle is found. */ export async function _getServerModuleBundle( - options: BuildWebpackPrerenderSchema, - context: BuilderContext, serverResult: BuilderOutputWithPaths, browserLocaleDirectory: string, ) { - let serverBundlePath; - if (options.appModuleBundle) { - serverBundlePath = path.join(context.workspaceRoot, options.appModuleBundle); - } else { - const { baseOutputPath = '' } = serverResult; - const outputPath = path.join(baseOutputPath, browserLocaleDirectory); - - if (!fs.existsSync(outputPath)) { - throw new Error(`Could not find server output directory: ${outputPath}.`); - } + const { baseOutputPath = '' } = serverResult; + const serverBundlePath = path.join(baseOutputPath, browserLocaleDirectory, 'main.js'); - const files = fs.readdirSync(outputPath, 'utf8'); - const re = /^main\.(?:[a-zA-Z0-9]{20}\.)?(?:bundle\.)?js$/; - const maybeMain = files.filter(x => re.test(x))[0]; - - if (!maybeMain) { - throw new Error('Could not find the main bundle.'); - } else { - serverBundlePath = path.join(outputPath, maybeMain); - } + if (!fs.existsSync(serverBundlePath)) { + throw new Error(`Could not find the main bundle: ${serverBundlePath}`); } const { diff --git a/modules/builders/src/prerender/schema.json b/modules/builders/src/prerender/schema.json index 77695edfa..a0b6804db 100644 --- a/modules/builders/src/prerender/schema.json +++ b/modules/builders/src/prerender/schema.json @@ -13,10 +13,6 @@ "description": "Server target to use for prerendering the app.", "pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$" }, - "appModuleBundle": { - "type": "string", - "description": "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)." - }, "routes": { "type": "array", "description": "The routes to render.", @@ -31,5 +27,6 @@ "browserTarget", "serverTarget", "routes" - ] + ], + "additionalProperties": false } From bf2ba636e292ddd01986e49dd91b66a694f45f98 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 6 Dec 2019 08:50:29 -0800 Subject: [PATCH 6/6] removed unnecessary import --- modules/builders/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/builders/src/index.ts b/modules/builders/src/index.ts index a53594684..731f52bd7 100644 --- a/modules/builders/src/index.ts +++ b/modules/builders/src/index.ts @@ -7,4 +7,3 @@ */ export * from './ssr-dev-server/index'; -export { } from './prerender/index';