Skip to content

Commit 72fceee

Browse files
wagnermacielvikerman
authored andcommitted
feat(builders): implement prerender (#1357)
The prerender builder starts the browser builder and the server builder. It then uses the server bundle to render an array of routes and writes the rendered html to [browser builder output path(s)]/[route]/index.html.
1 parent c643e71 commit 72fceee

File tree

6 files changed

+597
-1
lines changed

6 files changed

+597
-1
lines changed

modules/builders/BUILD.bazel

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
load("@npm_bazel_typescript//:index.bzl", "ts_config")
2-
load("//tools:defaults.bzl", "npm_package", "ts_library")
2+
load("//tools:defaults.bzl", "jasmine_node_test", "ng_test_library", "npm_package", "ts_library")
33

44
ts_config(
55
name = "bazel-tsconfig-build",
@@ -44,3 +44,20 @@ npm_package(
4444
tags = ["release"],
4545
deps = [":builders"],
4646
)
47+
48+
ng_test_library(
49+
name = "unit_test_lib",
50+
srcs = glob([
51+
"**/*.spec.ts",
52+
]),
53+
deps = [
54+
":builders",
55+
"@npm//@angular-devkit/architect",
56+
"@npm//@angular-devkit/core",
57+
],
58+
)
59+
60+
jasmine_node_test(
61+
name = "unit_test",
62+
srcs = [":unit_test_lib"],
63+
)

modules/builders/builders.json

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
"implementation": "./src/ssr-dev-server",
66
"schema": "./src/ssr-dev-server/schema.json",
77
"description": "Serve a universal application."
8+
},
9+
"prerender": {
10+
"implementation": "./src/prerender/index",
11+
"schema": "./src/prerender/schema.json",
12+
"description": "Perform build-time prerendering of chosen routes."
813
}
914
}
1015
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import * as PrerenderModule from './index';
9+
import { Schema } from './schema';
10+
11+
import { BuilderContext, BuilderRun } from '@angular-devkit/architect';
12+
import { JsonObject, logging } from '@angular-devkit/core';
13+
14+
import * as fs from 'fs';
15+
16+
const emptyFn = () => {};
17+
18+
describe('Prerender Builder', () => {
19+
const PROJECT_NAME = 'pokemon';
20+
let context: BuilderContext;
21+
let browserResult: PrerenderModule.BuilderOutputWithPaths;
22+
let serverResult: PrerenderModule.BuilderOutputWithPaths;
23+
let options: JsonObject & Schema;
24+
25+
beforeEach(() => {
26+
options = {
27+
browserTarget: `${PROJECT_NAME}:build`,
28+
serverTarget: `${PROJECT_NAME}:server`,
29+
routes: ['/'],
30+
};
31+
browserResult = {
32+
success: true,
33+
baseOutputPath: '',
34+
outputPaths: ['dist/browser'],
35+
} as PrerenderModule.BuilderOutputWithPaths;
36+
serverResult = {
37+
success: true,
38+
baseOutputPath: '',
39+
outputPaths: ['dist/server'],
40+
} as PrerenderModule.BuilderOutputWithPaths;
41+
context = createMockBuilderContext({
42+
logger: new logging.NullLogger(),
43+
workspaceRoot: '',
44+
});
45+
});
46+
47+
describe('#_prerender', () => {
48+
let scheduleTargetSpy: jasmine.Spy;
49+
let renderUniversalSpy: jasmine.Spy;
50+
let browserRun: BuilderRun;
51+
let serverRun: BuilderRun;
52+
53+
beforeEach(() => {
54+
browserRun = createMockBuilderRun({result: browserResult});
55+
serverRun = createMockBuilderRun({result: serverResult});
56+
spyOn(context, 'scheduleTarget')
57+
.and.returnValues(Promise.resolve(browserRun), Promise.resolve(serverRun));
58+
scheduleTargetSpy = context.scheduleTarget as jasmine.Spy;
59+
spyOn(PrerenderModule, '_renderUniversal').and.callFake(
60+
(_options: any, _context: any, _browserResult: any, _serverResult: any) => _browserResult
61+
);
62+
renderUniversalSpy = PrerenderModule._renderUniversal as jasmine.Spy;
63+
});
64+
65+
it('should schedule a build and server target', async () => {
66+
await PrerenderModule._prerender(options, context);
67+
expect(scheduleTargetSpy.calls.allArgs()).toEqual([
68+
[{project: PROJECT_NAME, target: 'build'}, {watch: false, serviceWorker: false}],
69+
[{project: PROJECT_NAME, target: 'server'}, {watch: false}],
70+
]);
71+
});
72+
73+
it('should call stop on the build and server run targets', async () => {
74+
spyOn(browserRun, 'stop');
75+
spyOn(serverRun, 'stop');
76+
await PrerenderModule._prerender(options, context);
77+
expect(browserRun.stop).toHaveBeenCalled();
78+
expect(serverRun.stop).toHaveBeenCalled();
79+
});
80+
81+
it('should call _renderUniversal', async () => {
82+
const result = await PrerenderModule._prerender(options, context);
83+
expect(result).toBe(await browserRun.result);
84+
expect(renderUniversalSpy.calls.allArgs()).toEqual([
85+
[options, context, browserResult, serverResult],
86+
]);
87+
});
88+
89+
it('should early exit if the browser build fails', async () => {
90+
const failedBrowserRun = createMockBuilderRun({
91+
result: {
92+
success: false,
93+
baseOutputPath: '',
94+
outputPaths: ['dist/browser'],
95+
}
96+
});
97+
scheduleTargetSpy.and.returnValues(
98+
Promise.resolve(failedBrowserRun),
99+
Promise.resolve(serverRun),
100+
);
101+
const result = await PrerenderModule._prerender(options, context);
102+
expect(result).toBe(await failedBrowserRun.result);
103+
expect(renderUniversalSpy).not.toHaveBeenCalled();
104+
});
105+
106+
it('should early exit if the browser build has no base output path', async () => {
107+
const failedBrowserRun = createMockBuilderRun({
108+
result: {
109+
success: true,
110+
baseOutputPath: undefined,
111+
outputPaths: ['dist/browser'],
112+
}
113+
});
114+
scheduleTargetSpy.and.returnValues(
115+
Promise.resolve(failedBrowserRun),
116+
Promise.resolve(serverRun),
117+
);
118+
const result = await PrerenderModule._prerender(options, context);
119+
expect(result).toBe(await failedBrowserRun.result);
120+
expect(renderUniversalSpy).not.toHaveBeenCalled();
121+
});
122+
123+
it('should early exit if the server build fails', async () => {
124+
const failedServerRun = createMockBuilderRun({
125+
result: {
126+
success: false,
127+
baseOutputPath: '',
128+
outputPaths: ['dist/server'],
129+
}
130+
});
131+
scheduleTargetSpy.and.returnValues(
132+
Promise.resolve(browserRun),
133+
Promise.resolve(failedServerRun),
134+
);
135+
const result = await PrerenderModule._prerender(options, context);
136+
expect(result).toBe(await failedServerRun.result);
137+
expect(renderUniversalSpy).not.toHaveBeenCalled();
138+
});
139+
140+
it('should catch errors thrown by _renderUniversal', async () => {
141+
const errmsg = 'Test _renderUniversal error.';
142+
const expected = {success: false, error: errmsg};
143+
renderUniversalSpy.and.callFake(() => {
144+
throw Error(errmsg);
145+
});
146+
await expectAsync(PrerenderModule._prerender(options, context)).toBeResolvedTo(expected);
147+
});
148+
149+
it('should throw if no routes are given', async () => {
150+
options.routes = [];
151+
const expectedError = new Error('No routes found. options.routes must contain at least one route to render.');
152+
await expectAsync(
153+
PrerenderModule._prerender(options, context)
154+
).toBeRejectedWith(expectedError);
155+
});
156+
});
157+
158+
describe('#_renderUniversal', () => {
159+
const INITIAL_HTML = '<html></html>';
160+
const RENDERED_HTML = '<html>[Rendered Content]</html>';
161+
let renderModuleFnSpy: jasmine.Spy;
162+
let readFileSyncSpy: jasmine.Spy;
163+
let mkdirSyncSpy: jasmine.Spy;
164+
let writeFileSyncSpy: jasmine.Spy;
165+
let getServerModuleBundleSpy: jasmine.Spy;
166+
167+
beforeEach(() => {
168+
renderModuleFnSpy = jasmine.createSpy('renderModuleFactory')
169+
.and.returnValue(Promise.resolve(RENDERED_HTML));
170+
spyOn(PrerenderModule, '_getServerModuleBundle').and.returnValue(Promise.resolve({
171+
renderModuleFn: renderModuleFnSpy,
172+
AppServerModuleDef: emptyFn,
173+
}));
174+
getServerModuleBundleSpy = PrerenderModule._getServerModuleBundle as jasmine.Spy;
175+
spyOn(fs, 'readFileSync').and.callFake(() => '<html></html>' as any);
176+
readFileSyncSpy = fs.readFileSync as jasmine.Spy;
177+
spyOn(fs, 'mkdirSync').and.callFake(emptyFn);
178+
mkdirSyncSpy = fs.mkdirSync as jasmine.Spy;
179+
spyOn(fs, 'writeFileSync').and.callFake(emptyFn);
180+
writeFileSyncSpy = fs.writeFileSync as jasmine.Spy;
181+
});
182+
183+
it('should use dist/browser/index.html as the base html', async () => {
184+
await PrerenderModule._renderUniversal(options, context, browserResult, serverResult);
185+
expect(readFileSyncSpy.calls.allArgs()).toEqual([
186+
['dist/browser/index.html', 'utf8'],
187+
]);
188+
});
189+
190+
it('should render each route', async () => {
191+
getServerModuleBundleSpy.and.returnValue(Promise.resolve({
192+
renderModuleFn: renderModuleFnSpy,
193+
AppServerModuleDef: emptyFn,
194+
}));
195+
options.routes = ['route1', 'route2', 'route3'];
196+
await PrerenderModule._renderUniversal(options, context, browserResult, serverResult);
197+
expect(renderModuleFnSpy.calls.allArgs()).toEqual([
198+
[emptyFn, {document: INITIAL_HTML, url: 'route1'}],
199+
[emptyFn, {document: INITIAL_HTML, url: 'route2'}],
200+
[emptyFn, {document: INITIAL_HTML, url: 'route3'}],
201+
]);
202+
});
203+
204+
it('should create a new directory for each route', async () => {
205+
options.routes = ['route1', 'route2'];
206+
await PrerenderModule._renderUniversal(options, context, browserResult, serverResult);
207+
expect(mkdirSyncSpy.calls.allArgs()).toEqual([
208+
['dist/browser/route1'],
209+
['dist/browser/route2'],
210+
]);
211+
});
212+
213+
it('should write to "index/index.html" for route "/"', async () => {
214+
await PrerenderModule._renderUniversal(options, context, browserResult, serverResult);
215+
expect(mkdirSyncSpy.calls.allArgs()).toEqual([
216+
['dist/browser/index'],
217+
]);
218+
expect(writeFileSyncSpy.calls.allArgs()).toEqual([
219+
['dist/browser/index/index.html', RENDERED_HTML],
220+
]);
221+
});
222+
223+
it('should try to write the rendered html for each route to "route/index.html"', async () => {
224+
options.routes = ['route1', 'route2'];
225+
await PrerenderModule._renderUniversal(options, context, browserResult, serverResult);
226+
expect(writeFileSyncSpy.calls.allArgs()).toEqual([
227+
['dist/browser/route1/index.html', RENDERED_HTML],
228+
['dist/browser/route2/index.html', RENDERED_HTML],
229+
]);
230+
});
231+
232+
it('should catch errors thrown when writing the rendered html', async () => {
233+
mkdirSyncSpy.and.callFake(() => {
234+
throw new Error('Test mkdirSync error.');
235+
});
236+
await expectAsync(
237+
PrerenderModule._renderUniversal(
238+
options,
239+
context,
240+
browserResult,
241+
serverResult
242+
)
243+
).not.toBeRejected();
244+
expect(mkdirSyncSpy).toHaveBeenCalled();
245+
expect(writeFileSyncSpy).not.toHaveBeenCalled();
246+
});
247+
});
248+
249+
describe('#_getServerModuleBundle', () => {
250+
const browserDirectory = 'dist/server';
251+
let importSpy: jasmine.Spy;
252+
let existsSyncSpy: jasmine.Spy;
253+
254+
beforeEach(() => {
255+
spyOn(PrerenderModule, '_importWrapper').and.returnValue(Promise.resolve({
256+
renderModule: emptyFn,
257+
AppServerModule: emptyFn,
258+
}));
259+
importSpy = PrerenderModule._importWrapper as jasmine.Spy;
260+
spyOn(fs, 'existsSync').and.returnValue(true);
261+
existsSyncSpy = fs.existsSync as jasmine.Spy;
262+
});
263+
264+
it('return a serverModuleBundle', async () => {
265+
await expectAsync(
266+
PrerenderModule._getServerModuleBundle(
267+
serverResult,
268+
browserDirectory
269+
)
270+
).toBeResolvedTo({
271+
renderModuleFn: emptyFn,
272+
AppServerModuleDef: emptyFn,
273+
});
274+
});
275+
276+
it('return a serverModuleBundle from factories', async () => {
277+
importSpy.and.returnValue(Promise.resolve({
278+
renderModuleFactory: emptyFn,
279+
AppServerModuleNgFactory: emptyFn,
280+
}));
281+
await expectAsync(
282+
PrerenderModule._getServerModuleBundle(
283+
serverResult,
284+
browserDirectory
285+
)
286+
).toBeResolvedTo({
287+
renderModuleFn: emptyFn,
288+
AppServerModuleDef: emptyFn,
289+
});
290+
});
291+
292+
it('should throw if the server bundle file does not exist', async () => {
293+
existsSyncSpy.and.returnValue(false);
294+
const expectedError = new Error(`Could not find the main bundle: dist/server/main.js`);
295+
await expectAsync(
296+
PrerenderModule._getServerModuleBundle(
297+
serverResult,
298+
browserDirectory
299+
)
300+
).toBeRejectedWith(expectedError);
301+
});
302+
303+
it('should throw if no serverModuleBundle is defined', async () => {
304+
importSpy.and.returnValue(Promise.resolve({}));
305+
const expectedError = new Error('renderModule method and/or AppServerModule were not exported from: dist/server/main.js.');
306+
await expectAsync(
307+
PrerenderModule._getServerModuleBundle(serverResult, browserDirectory)
308+
).toBeRejectedWith(expectedError);
309+
});
310+
});
311+
});
312+
313+
function createMockBuilderContext(overrides?: object) {
314+
const context = {
315+
id: null,
316+
builder: null,
317+
logger: null,
318+
workspaceRoot: null,
319+
currentDirectory: null,
320+
target: null,
321+
analytics: null,
322+
scheduleTarget: emptyFn,
323+
scheduleBuilder: emptyFn,
324+
getTargetOptions: emptyFn,
325+
getProjectMetadata: emptyFn,
326+
getBuilderNameForTarget: emptyFn,
327+
validateOptions: emptyFn,
328+
reportRunning: emptyFn,
329+
reportStatus: emptyFn,
330+
reportProgress: emptyFn,
331+
addTeardown: emptyFn,
332+
...overrides,
333+
};
334+
return context as unknown as BuilderContext;
335+
}
336+
337+
function createMockBuilderRun(overrides?: object) {
338+
const run = {
339+
id: null,
340+
info: null,
341+
result: null,
342+
output: null,
343+
progress: null,
344+
stop: emptyFn,
345+
...overrides,
346+
};
347+
return run as unknown as BuilderRun;
348+
}

0 commit comments

Comments
 (0)