Skip to content

Commit 8c0779f

Browse files
Broccohansl
authored andcommitted
feat(@angular/cli): Add ability to build AppShell
1 parent e3e04c5 commit 8c0779f

File tree

6 files changed

+263
-1
lines changed

6 files changed

+263
-1
lines changed

packages/@angular/cli/commands/build.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { CliConfig } from '../models/config';
22
import { BuildOptions } from '../models/build-options';
33
import { Version } from '../upgrade/version';
44
import { oneLine } from 'common-tags';
5+
import { getAppFromConfig } from '../utilities/app-utils';
6+
import { join } from 'path';
7+
import { RenderUniversalTaskOptions } from '../tasks/render-universal';
58

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

@@ -198,6 +201,12 @@ export const baseBuildCommandOptions: any = [
198201
aliases: ['sw'],
199202
description: 'Generates a service worker config for production builds, if the app has '
200203
+ 'service worker enabled.'
204+
},
205+
{
206+
name: 'skip-app-shell',
207+
type: Boolean,
208+
description: 'Flag to prevent building an app shell',
209+
default: false
201210
}
202211
];
203212

@@ -237,7 +246,45 @@ const BuildCommand = Command.extend({
237246
ui: this.ui,
238247
});
239248

240-
return buildTask.run(commandOptions);
249+
250+
const buildPromise = buildTask.run(commandOptions);
251+
252+
253+
const clientApp = getAppFromConfig(commandOptions.app);
254+
255+
const doAppShell = commandOptions.target === 'production' &&
256+
(commandOptions.aot === undefined || commandOptions.aot === true) &&
257+
!commandOptions.skipAppShell;
258+
if (!clientApp.appShell || !doAppShell) {
259+
return buildPromise;
260+
}
261+
const serverApp = getAppFromConfig(clientApp.appShell.app);
262+
263+
return buildPromise
264+
.then(() => {
265+
266+
const serverOptions = {
267+
...commandOptions,
268+
app: clientApp.appShell.app
269+
};
270+
return buildTask.run(serverOptions);
271+
})
272+
.then(() => {
273+
const RenderUniversalTask = require('../tasks/render-universal').default;
274+
275+
const renderUniversalTask = new RenderUniversalTask({
276+
project: this.project,
277+
ui: this.ui,
278+
});
279+
const renderUniversalOptions: RenderUniversalTaskOptions = {
280+
inputIndexPath: join(this.project.root, clientApp.outDir, clientApp.index),
281+
route: clientApp.appShell.route,
282+
serverOutDir: join(this.project.root, serverApp.outDir),
283+
outputIndexPath: join(this.project.root, clientApp.outDir, clientApp.index)
284+
};
285+
286+
return renderUniversalTask.run(renderUniversalOptions);
287+
});
241288
}
242289
});
243290

packages/@angular/cli/lib/config/schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@
3838
"description": "Directory where app files are placed.",
3939
"default": "app"
4040
},
41+
"appShell": {
42+
"type": "object",
43+
"description": "AppShell configuration.",
44+
"properties": {
45+
"app": {
46+
"type": "string",
47+
"description": "Index or name of the related AppShell app."
48+
},
49+
"route": {
50+
"type": "string",
51+
"description": "Default AppShell route to render."
52+
}
53+
}
54+
},
4155
"root": {
4256
"type": "string",
4357
"description": "The root directory of the app."

packages/@angular/cli/models/build-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ export interface BuildOptions {
3232
subresourceIntegrity?: boolean;
3333
forceTsCommonjs?: boolean;
3434
serviceWorker?: boolean;
35+
skipAppShell?: boolean;
3536
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { requireProjectModule } from '../utilities/require-project-module';
2+
import { join } from 'path';
3+
4+
const fs = require('fs');
5+
const Task = require('../ember-cli/lib/models/task');
6+
7+
export interface RenderUniversalTaskOptions {
8+
inputIndexPath: string;
9+
route: string;
10+
serverOutDir: string;
11+
outputIndexPath: string;
12+
}
13+
14+
export default Task.extend({
15+
run: function(options: RenderUniversalTaskOptions): Promise<any> {
16+
require('zone.js/dist/zone-node');
17+
18+
const renderModuleFactory =
19+
requireProjectModule(this.project.root, '@angular/platform-server').renderModuleFactory;
20+
21+
// Get the main bundle from the server build's output directory.
22+
const serverDir = fs.readdirSync(options.serverOutDir);
23+
const serverMainBundle = serverDir
24+
.filter((file: string) => /main\.[a-zA-Z0-9]{20}.bundle\.js/.test(file))[0];
25+
const serverBundlePath = join(options.serverOutDir, serverMainBundle);
26+
const AppServerModuleNgFactory = require(serverBundlePath).AppServerModuleNgFactory;
27+
28+
const index = fs.readFileSync(options.inputIndexPath, 'utf8');
29+
// Render to HTML and overwrite the client index file.
30+
return renderModuleFactory(AppServerModuleNgFactory, {document: index, url: options.route})
31+
.then((html: string) => fs.writeFileSync(options.outputIndexPath, html));
32+
}
33+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ng, npm } from '../../utils/process';
2+
import { expectFileToMatch } from '../../utils/fs';
3+
import { getGlobalVariable } from '../../utils/env';
4+
import { expectToFail } from '../../utils/utils';
5+
6+
7+
export default function () {
8+
// Skip this in ejected tests.
9+
if (getGlobalVariable('argv').eject) {
10+
return Promise.resolve();
11+
}
12+
13+
// Skip in nightly tests.
14+
if (getGlobalVariable('argv').nightly) {
15+
return Promise.resolve();
16+
}
17+
18+
return Promise.resolve()
19+
.then(() => ng('generate', 'appShell', 'name', '--universal-app', 'universal'))
20+
.then(() => npm('install'))
21+
.then(() => ng('build', '--prod'))
22+
.then(() => expectFileToMatch('dist/index.html', /app-shell works!/))
23+
.then(() => ng('build', '--prod', '--skip-app-shell'))
24+
.then(() => expectToFail(() => expectFileToMatch('dist/index.html', /app-shell works!/)));
25+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { ng, npm } from '../../utils/process';
2+
import { expectFileToMatch, writeFile } from '../../utils/fs';
3+
import { getGlobalVariable } from '../../utils/env';
4+
import { expectToFail } from '../../utils/utils';
5+
import { updateJsonFile } from '../../utils/project';
6+
import { readNgVersion } from '../../utils/version';
7+
import { stripIndent } from 'common-tags';
8+
9+
10+
export default function () {
11+
// Skip this in ejected tests.
12+
if (getGlobalVariable('argv').eject) {
13+
return Promise.resolve();
14+
}
15+
16+
let platformServerVersion = readNgVersion();
17+
18+
if (getGlobalVariable('argv').nightly) {
19+
platformServerVersion = 'github:angular/platform-server-builds';
20+
}
21+
22+
return Promise.resolve()
23+
.then(() => updateJsonFile('.angular-cli.json', configJson => {
24+
const app = configJson['apps'][0];
25+
app['appShell'] = {
26+
app: '1',
27+
route: 'shell'
28+
};
29+
configJson['apps'].push({
30+
platform: 'server',
31+
root: 'src',
32+
outDir: 'dist-server',
33+
assets: [
34+
'assets',
35+
'favicon.ico'
36+
],
37+
index: 'index.html',
38+
main: 'main.server.ts',
39+
test: 'test.ts',
40+
tsconfig: 'tsconfig.server.json',
41+
testTsconfig: 'tsconfig.spec.json',
42+
prefix: 'app',
43+
styles: [
44+
'styles.css'
45+
],
46+
scripts: [],
47+
environmentSource: 'environments/environment.ts',
48+
environments: {
49+
dev: 'environments/environment.ts',
50+
prod: 'environments/environment.prod.ts'
51+
}
52+
});
53+
}))
54+
.then(() => writeFile('src/app/app.module.ts', stripIndent`
55+
import { BrowserModule } from '@angular/platform-browser';
56+
import { NgModule } from '@angular/core';
57+
import { RouterModule } from '@angular/router';
58+
59+
import { AppComponent } from './app.component';
60+
61+
@NgModule({
62+
imports: [
63+
BrowserModule.withServerTransition({ appId: 'appshell-play' }),
64+
RouterModule
65+
],
66+
declarations: [AppComponent],
67+
bootstrap: [AppComponent]
68+
})
69+
export class AppModule { }
70+
`))
71+
.then(() => writeFile('src/app/app.component.html', stripIndent`
72+
Hello World
73+
<router-outlet></router-outlet>
74+
`))
75+
.then(() => writeFile('src/tsconfig.server.json', stripIndent`
76+
{
77+
"extends": "../tsconfig.json",
78+
"compilerOptions": {
79+
"outDir": "../out-tsc/app",
80+
"baseUrl": "./",
81+
"module": "commonjs",
82+
"types": []
83+
},
84+
"exclude": [
85+
"test.ts",
86+
"**/*.spec.ts"
87+
],
88+
"angularCompilerOptions": {
89+
"entryModule": "app/app.server.module#AppServerModule"
90+
}
91+
}
92+
`))
93+
.then(() => writeFile('src/main.server.ts', stripIndent`
94+
export {AppServerModule} from './app/app.server.module';
95+
`))
96+
.then(() => writeFile('src/app/app.server.module.ts', stripIndent`
97+
import {NgModule} from '@angular/core';
98+
import {ServerModule} from '@angular/platform-server';
99+
import { Routes, RouterModule } from '@angular/router';
100+
101+
import { AppModule } from './app.module';
102+
import { AppComponent } from './app.component';
103+
import { ShellComponent } from './shell.component';
104+
105+
const routes: Routes = [
106+
{ path: 'shell', component: ShellComponent }
107+
];
108+
109+
@NgModule({
110+
imports: [
111+
// The AppServerModule should import your AppModule followed
112+
// by the ServerModule from @angular/platform-server.
113+
AppModule,
114+
ServerModule,
115+
RouterModule.forRoot(routes),
116+
],
117+
// Since the bootstrapped component is not inherited from your
118+
// imported AppModule, it needs to be repeated here.
119+
bootstrap: [AppComponent],
120+
declarations: [ShellComponent],
121+
})
122+
export class AppServerModule {}
123+
`))
124+
.then(() => writeFile('src/app/shell.component.ts', stripIndent`
125+
import { Component } from '@angular/core';
126+
@Component({
127+
selector: 'app-shell',
128+
template: '<p>shell Works!</p>',
129+
styles: []
130+
})
131+
export class ShellComponent {}
132+
`))
133+
.then(() => updateJsonFile('package.json', packageJson => {
134+
const dependencies = packageJson['dependencies'];
135+
dependencies['@angular/platform-server'] = platformServerVersion;
136+
})
137+
.then(() => npm('install')))
138+
.then(() => ng('build', '--prod'))
139+
.then(() => expectFileToMatch('dist/index.html', /shell Works!/))
140+
.then(() => ng('build', '--prod', '--skip-app-shell'))
141+
.then(() => expectToFail(() => expectFileToMatch('dist/index.html', /shell Works!/)));
142+
}

0 commit comments

Comments
 (0)