Skip to content

Commit

Permalink
feat(@angular/cli): Add ability to build AppShell
Browse files Browse the repository at this point in the history
  • Loading branch information
Brocco authored and hansl committed Nov 23, 2017
1 parent e3e04c5 commit 8c0779f
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 1 deletion.
49 changes: 48 additions & 1 deletion packages/@angular/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { CliConfig } from '../models/config';
import { BuildOptions } from '../models/build-options';
import { Version } from '../upgrade/version';
import { oneLine } from 'common-tags';
import { getAppFromConfig } from '../utilities/app-utils';
import { join } from 'path';
import { RenderUniversalTaskOptions } from '../tasks/render-universal';

const Command = require('../ember-cli/lib/models/command');

Expand Down Expand Up @@ -198,6 +201,12 @@ export const baseBuildCommandOptions: any = [
aliases: ['sw'],
description: 'Generates a service worker config for production builds, if the app has '
+ 'service worker enabled.'
},
{
name: 'skip-app-shell',
type: Boolean,
description: 'Flag to prevent building an app shell',
default: false
}
];

Expand Down Expand Up @@ -237,7 +246,45 @@ const BuildCommand = Command.extend({
ui: this.ui,
});

return buildTask.run(commandOptions);

const buildPromise = buildTask.run(commandOptions);


const clientApp = getAppFromConfig(commandOptions.app);

const doAppShell = commandOptions.target === 'production' &&
(commandOptions.aot === undefined || commandOptions.aot === true) &&
!commandOptions.skipAppShell;
if (!clientApp.appShell || !doAppShell) {
return buildPromise;
}
const serverApp = getAppFromConfig(clientApp.appShell.app);

return buildPromise
.then(() => {

const serverOptions = {
...commandOptions,
app: clientApp.appShell.app
};
return buildTask.run(serverOptions);
})
.then(() => {
const RenderUniversalTask = require('../tasks/render-universal').default;

const renderUniversalTask = new RenderUniversalTask({
project: this.project,
ui: this.ui,
});
const renderUniversalOptions: RenderUniversalTaskOptions = {
inputIndexPath: join(this.project.root, clientApp.outDir, clientApp.index),
route: clientApp.appShell.route,
serverOutDir: join(this.project.root, serverApp.outDir),
outputIndexPath: join(this.project.root, clientApp.outDir, clientApp.index)
};

return renderUniversalTask.run(renderUniversalOptions);
});
}
});

Expand Down
14 changes: 14 additions & 0 deletions packages/@angular/cli/lib/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@
"description": "Directory where app files are placed.",
"default": "app"
},
"appShell": {
"type": "object",
"description": "AppShell configuration.",
"properties": {
"app": {
"type": "string",
"description": "Index or name of the related AppShell app."
},
"route": {
"type": "string",
"description": "Default AppShell route to render."
}
}
},
"root": {
"type": "string",
"description": "The root directory of the app."
Expand Down
1 change: 1 addition & 0 deletions packages/@angular/cli/models/build-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ export interface BuildOptions {
subresourceIntegrity?: boolean;
forceTsCommonjs?: boolean;
serviceWorker?: boolean;
skipAppShell?: boolean;
}
33 changes: 33 additions & 0 deletions packages/@angular/cli/tasks/render-universal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { requireProjectModule } from '../utilities/require-project-module';
import { join } from 'path';

const fs = require('fs');
const Task = require('../ember-cli/lib/models/task');

export interface RenderUniversalTaskOptions {
inputIndexPath: string;
route: string;
serverOutDir: string;
outputIndexPath: string;
}

export default Task.extend({
run: function(options: RenderUniversalTaskOptions): Promise<any> {
require('zone.js/dist/zone-node');

const renderModuleFactory =
requireProjectModule(this.project.root, '@angular/platform-server').renderModuleFactory;

// Get the main bundle from the server build's output directory.
const serverDir = fs.readdirSync(options.serverOutDir);
const serverMainBundle = serverDir
.filter((file: string) => /main\.[a-zA-Z0-9]{20}.bundle\.js/.test(file))[0];
const serverBundlePath = join(options.serverOutDir, serverMainBundle);
const AppServerModuleNgFactory = require(serverBundlePath).AppServerModuleNgFactory;

const index = fs.readFileSync(options.inputIndexPath, 'utf8');
// Render to HTML and overwrite the client index file.
return renderModuleFactory(AppServerModuleNgFactory, {document: index, url: options.route})
.then((html: string) => fs.writeFileSync(options.outputIndexPath, html));
}
});
25 changes: 25 additions & 0 deletions tests/e2e/tests/build/build-app-shell-with-schematic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ng, npm } from '../../utils/process';
import { expectFileToMatch } from '../../utils/fs';
import { getGlobalVariable } from '../../utils/env';
import { expectToFail } from '../../utils/utils';


export default function () {
// Skip this in ejected tests.
if (getGlobalVariable('argv').eject) {
return Promise.resolve();
}

// Skip in nightly tests.
if (getGlobalVariable('argv').nightly) {
return Promise.resolve();
}

return Promise.resolve()
.then(() => ng('generate', 'appShell', 'name', '--universal-app', 'universal'))
.then(() => npm('install'))
.then(() => ng('build', '--prod'))
.then(() => expectFileToMatch('dist/index.html', /app-shell works!/))
.then(() => ng('build', '--prod', '--skip-app-shell'))
.then(() => expectToFail(() => expectFileToMatch('dist/index.html', /app-shell works!/)));
}
142 changes: 142 additions & 0 deletions tests/e2e/tests/build/build-app-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { ng, npm } from '../../utils/process';
import { expectFileToMatch, writeFile } from '../../utils/fs';
import { getGlobalVariable } from '../../utils/env';
import { expectToFail } from '../../utils/utils';
import { updateJsonFile } from '../../utils/project';
import { readNgVersion } from '../../utils/version';
import { stripIndent } from 'common-tags';


export default function () {
// Skip this in ejected tests.
if (getGlobalVariable('argv').eject) {
return Promise.resolve();
}

let platformServerVersion = readNgVersion();

if (getGlobalVariable('argv').nightly) {
platformServerVersion = 'github:angular/platform-server-builds';
}

return Promise.resolve()
.then(() => updateJsonFile('.angular-cli.json', configJson => {
const app = configJson['apps'][0];
app['appShell'] = {
app: '1',
route: 'shell'
};
configJson['apps'].push({
platform: 'server',
root: 'src',
outDir: 'dist-server',
assets: [
'assets',
'favicon.ico'
],
index: 'index.html',
main: 'main.server.ts',
test: 'test.ts',
tsconfig: 'tsconfig.server.json',
testTsconfig: 'tsconfig.spec.json',
prefix: 'app',
styles: [
'styles.css'
],
scripts: [],
environmentSource: 'environments/environment.ts',
environments: {
dev: 'environments/environment.ts',
prod: 'environments/environment.prod.ts'
}
});
}))
.then(() => writeFile('src/app/app.module.ts', stripIndent`
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
@NgModule({
imports: [
BrowserModule.withServerTransition({ appId: 'appshell-play' }),
RouterModule
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule { }
`))
.then(() => writeFile('src/app/app.component.html', stripIndent`
Hello World
<router-outlet></router-outlet>
`))
.then(() => writeFile('src/tsconfig.server.json', stripIndent`
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "commonjs",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "app/app.server.module#AppServerModule"
}
}
`))
.then(() => writeFile('src/main.server.ts', stripIndent`
export {AppServerModule} from './app/app.server.module';
`))
.then(() => writeFile('src/app/app.server.module.ts', stripIndent`
import {NgModule} from '@angular/core';
import {ServerModule} from '@angular/platform-server';
import { Routes, RouterModule } from '@angular/router';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { ShellComponent } from './shell.component';
const routes: Routes = [
{ path: 'shell', component: ShellComponent }
];
@NgModule({
imports: [
// The AppServerModule should import your AppModule followed
// by the ServerModule from @angular/platform-server.
AppModule,
ServerModule,
RouterModule.forRoot(routes),
],
// Since the bootstrapped component is not inherited from your
// imported AppModule, it needs to be repeated here.
bootstrap: [AppComponent],
declarations: [ShellComponent],
})
export class AppServerModule {}
`))
.then(() => writeFile('src/app/shell.component.ts', stripIndent`
import { Component } from '@angular/core';
@Component({
selector: 'app-shell',
template: '<p>shell Works!</p>',
styles: []
})
export class ShellComponent {}
`))
.then(() => updateJsonFile('package.json', packageJson => {
const dependencies = packageJson['dependencies'];
dependencies['@angular/platform-server'] = platformServerVersion;
})
.then(() => npm('install')))
.then(() => ng('build', '--prod'))
.then(() => expectFileToMatch('dist/index.html', /shell Works!/))
.then(() => ng('build', '--prod', '--skip-app-shell'))
.then(() => expectToFail(() => expectFileToMatch('dist/index.html', /shell Works!/)));
}

0 comments on commit 8c0779f

Please sign in to comment.