Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add AppShell new API builder
Browse files Browse the repository at this point in the history
This is fully compatible with the new API. The tests have been moved 1:1.
The cleanup in general is noticable.
  • Loading branch information
hansl authored and Keen Yee Liau committed Mar 25, 2019
1 parent 69e4103 commit 872799e
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 49 deletions.
1 change: 1 addition & 0 deletions packages/angular_devkit/build_angular/builders.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"builders": {
"app-shell": {
"class": "./src/app-shell",
"implementation": "./src/app-shell/index2",
"schema": "./src/app-shell/schema.json",
"description": "Build a server app and a browser app, then render the index.html and use it for the browser output."
},
Expand Down
161 changes: 161 additions & 0 deletions packages/angular_devkit/build_angular/src/app-shell/index2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* @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 {
BuilderContext,
BuilderOutput,
createBuilder,
targetFromTargetString,
} from '@angular-devkit/architect/src/index2';
import { JsonObject, experimental, join, normalize, resolve, schema } from '@angular-devkit/core';
import { NodeJsSyncHost } from '@angular-devkit/core/node';
import * as fs from 'fs';
import * as path from 'path';
import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module';
import { augmentAppWithServiceWorker } from '../angular-cli-files/utilities/service-worker';
import { BrowserBuilderOutput } from '../browser/index2';
import { Schema as BrowserBuilderSchema } from '../browser/schema';
import { ServerBuilderOutput } from '../server/index2';
import { Schema as BuildWebpackAppShellSchema } from './schema';


async function _renderUniversal(
options: BuildWebpackAppShellSchema,
context: BuilderContext,
browserResult: BrowserBuilderOutput,
serverResult: ServerBuilderOutput,
): Promise<BrowserBuilderOutput> {
const browserIndexOutputPath = path.join(browserResult.outputPath || '', 'index.html');
const indexHtml = fs.readFileSync(browserIndexOutputPath, 'utf8');
const serverBundlePath = await _getServerModuleBundlePath(options, context, serverResult);

const root = context.workspaceRoot;
requireProjectModule(root, 'zone.js/dist/zone-node');

const renderModuleFactory = requireProjectModule(
root,
'@angular/platform-server',
).renderModuleFactory;
const AppServerModuleNgFactory = require(serverBundlePath).AppServerModuleNgFactory;
const outputIndexPath = options.outputIndexPath
? path.join(root, options.outputIndexPath)
: browserIndexOutputPath;

// Render to HTML and overwrite the client index file.
const html = await renderModuleFactory(AppServerModuleNgFactory, {
document: indexHtml,
url: options.route,
});

fs.writeFileSync(outputIndexPath, html);

const browserTarget = targetFromTargetString(options.browserTarget);
const rawBrowserOptions = await context.getTargetOptions(browserTarget);
const browserBuilderName = await context.getBuilderNameForTarget(browserTarget);
const browserOptions = await context.validateOptions<JsonObject & BrowserBuilderSchema>(
rawBrowserOptions,
browserBuilderName,
);

if (browserOptions.serviceWorker) {
const host = new NodeJsSyncHost();
// Create workspace.
const registry = new schema.CoreSchemaRegistry();
registry.addPostTransform(schema.transforms.addUndefinedDefaults);

const workspace = await experimental.workspace.Workspace.fromPath(
host,
normalize(context.workspaceRoot),
registry,
);
const projectName = context.target ? context.target.project : workspace.getDefaultProjectName();

if (!projectName) {
throw new Error('Must either have a target from the context or a default project.');
}
const projectRoot = resolve(
workspace.root,
normalize(workspace.getProject(projectName).root),
);

await augmentAppWithServiceWorker(
host,
normalize(root),
projectRoot,
join(normalize(root), browserOptions.outputPath),
browserOptions.baseHref || '/',
browserOptions.ngswConfigPath,
);
}

return browserResult;
}


async function _getServerModuleBundlePath(
options: BuildWebpackAppShellSchema,
context: BuilderContext,
serverResult: ServerBuilderOutput,
) {
if (options.appModuleBundle) {
return path.join(context.workspaceRoot, options.appModuleBundle);
} else {
const outputPath = serverResult.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 {
return path.join(outputPath, maybeMain);
}
}
}


async function _appShellBuilder(
options: JsonObject & BuildWebpackAppShellSchema,
context: BuilderContext,
): Promise<BuilderOutput> {
const browserTarget = targetFromTargetString(options.browserTarget);
const serverTarget = targetFromTargetString(options.serverTarget);

// Never run the browser target in watch mode.
// If service worker is needed, it will be added in _renderUniversal();
const browserTargetRun = await context.scheduleTarget(browserTarget, {
watch: false,
serviceWorker: false,
});
const serverTargetRun = await context.scheduleTarget(serverTarget, {});

try {
const [browserResult, serverResult] = await Promise.all([
browserTargetRun.result as {} as BrowserBuilderOutput,
serverTargetRun.result,
]);

if (browserResult.success === false || browserResult.outputPath === undefined) {
return browserResult;
} else if (serverResult.success === false) {
return serverResult;
}

return await _renderUniversal(options, context, browserResult, serverResult);
} catch (err) {
return { success: false, error: err.message };
} finally {
// Just be good citizens and stop those jobs.
await Promise.all([
browserTargetRun.stop(),
serverTargetRun.stop(),
]);
}
}


export default createBuilder(_appShellBuilder);
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
*/
// tslint:disable:no-big-function

import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing';
import { getSystemPath, join, normalize, virtualFs } from '@angular-devkit/core';
import * as express from 'express'; // tslint:disable-line:no-implicit-dependencies
import { Server } from 'http';
import { concatMap, tap } from 'rxjs/operators';
import { host, protractorTargetSpec } from '../utils';
import { Architect } from '../../../architect/src/architect';
import { createArchitect, host } from '../utils';


describe('AppShell Builder', () => {
beforeEach(done => host.initialize().toPromise().then(done, done.fail));
afterEach(done => host.restore().toPromise().then(done, done.fail));
const target = { project: 'app', target: 'app-shell' };
let architect: Architect;

beforeEach(async () => {
await host.initialize().toPromise();
architect = (await createArchitect(host.root())).architect;
});
afterEach(async () => host.restore().toPromise());

const targetSpec = { project: 'app', target: 'app-shell' };
const appShellRouteFiles = {
'src/app/app-shell/app-shell.component.html': `
<p>
Expand Down Expand Up @@ -120,36 +123,37 @@ describe('AppShell Builder', () => {
`,
};

it('works (basic)', done => {
it('works (basic)', async () => {
host.replaceInFile('src/app/app.module.ts', / BrowserModule/, `
BrowserModule.withServerTransition({ appId: 'some-app' })
`);

runTargetSpec(host, targetSpec, {}, DefaultTimeout * 2).pipe(
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
tap(() => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toMatch(/Welcome to app!/);
}),
).toPromise().then(done, done.fail);
const run = await architect.scheduleTarget(target);
const output = await run.result;
await run.stop();

expect(output.success).toBe(true);

const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toMatch(/Welcome to app!/);
});

it('works with route', done => {
it('works with route', async () => {
host.writeMultipleFiles(appShellRouteFiles);
const overrides = { route: 'shell' };

runTargetSpec(host, targetSpec, overrides, DefaultTimeout * 2).pipe(
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
tap(() => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toContain('app-shell works!');
}),
).toPromise().then(done, done.fail);
const run = await architect.scheduleTarget(target, overrides);
const output = await run.result;
await run.stop();

expect(output.success).toBe(true);
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toContain('app-shell works!');
});

it('works with route and service-worker', done => {
it('works with route and service-worker', async () => {
host.writeMultipleFiles(appShellRouteFiles);
host.writeMultipleFiles({
'src/ngsw-config.json': `
Expand Down Expand Up @@ -225,29 +229,39 @@ describe('AppShell Builder', () => {
'"buildOptimizer": true, "serviceWorker": true',
);

// We're changing the workspace file so we need to recreate the Architect instance.
architect = (await createArchitect(host.root())).architect;

const overrides = { route: 'shell' };
const prodTargetSpec = { ...targetSpec, configuration: 'production' };
let server: Server;

// Build the app shell.
runTargetSpec(host, prodTargetSpec, overrides, DefaultTimeout * 2).pipe(
tap((buildEvent) => expect(buildEvent.success).toBe(true)),
// Make sure the index is pre-rendering the route.
tap(() => {
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toContain('app-shell works!');
}),
tap(() => {
// Serve the app using a simple static server.
const app = express();
app.use('/', express.static(getSystemPath(join(host.root(), 'dist')) + '/'));
server = app.listen(4200);
}),
// Load app in protractor, then check service worker status.
concatMap(() => runTargetSpec(host, protractorTargetSpec, { devServerTarget: undefined })),
// Close the express server.
tap(() => server.close()),
).toPromise().then(done, done.fail);
const run = await architect.scheduleTarget(
{ ...target, configuration: 'production' },
overrides,
);
const output = await run.result;
await run.stop();

expect(output.success).toBe(true);

// Make sure the index is pre-rendering the route.
const fileName = 'dist/index.html';
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
expect(content).toContain('app-shell works!');

// Serve the app using a simple static server.
const app = express();
app.use('/', express.static(getSystemPath(join(host.root(), 'dist')) + '/'));
const server = app.listen(4200);

// Load app in protractor, then check service worker status.
const protractorRun = await architect.scheduleTarget(
{ project: 'app-e2e', target: 'e2e' },
{ devServerTarget: undefined } as {},
);
const protractorOutput = await protractorRun.result;
await protractorRun.stop();
expect(protractorOutput.success).toBe(true);

// Close the express server.
server.close();
});
});

0 comments on commit 872799e

Please sign in to comment.