mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-18 20:02:40 +08:00
In case the browser builder hashes the assets we need to add this setting to the server builder as otherwise when assets it will be requested twice. One for the server which will be unhashed, and other on the client which will be hashed. Closes #15953
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
/**
|
|
* @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 {
|
|
JsonParseMode,
|
|
Path,
|
|
basename,
|
|
join,
|
|
normalize,
|
|
parseJson,
|
|
strings,
|
|
} from '@angular-devkit/core';
|
|
import {
|
|
Rule,
|
|
SchematicContext,
|
|
SchematicsException,
|
|
Tree,
|
|
apply,
|
|
applyTemplates,
|
|
chain,
|
|
mergeWith,
|
|
move,
|
|
url,
|
|
} from '@angular-devkit/schematics';
|
|
import {
|
|
NodePackageInstallTask,
|
|
} from '@angular-devkit/schematics/tasks';
|
|
import * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript';
|
|
import { findNode, getDecoratorMetadata } from '../utility/ast-utils';
|
|
import { InsertChange } from '../utility/change';
|
|
import { addPackageJsonDependency, getPackageJsonDependency } from '../utility/dependencies';
|
|
import { findBootstrapModuleCall, findBootstrapModulePath } from '../utility/ng-ast-utils';
|
|
import { targetBuildNotFoundError } from '../utility/project-targets';
|
|
import { getWorkspace, updateWorkspace } from '../utility/workspace';
|
|
import { BrowserBuilderOptions, Builders, OutputHashing } from '../utility/workspace-models';
|
|
import { Schema as UniversalOptions } from './schema';
|
|
|
|
function updateConfigFile(options: UniversalOptions, tsConfigDirectory: Path): Rule {
|
|
return updateWorkspace(workspace => {
|
|
const clientProject = workspace.projects.get(options.clientProject);
|
|
if (clientProject) {
|
|
const buildTarget = clientProject.targets.get('build');
|
|
let fileReplacements;
|
|
if (buildTarget && buildTarget.configurations && buildTarget.configurations.production) {
|
|
fileReplacements = buildTarget.configurations.production.fileReplacements;
|
|
}
|
|
|
|
if (buildTarget && buildTarget.options) {
|
|
buildTarget.options.outputPath = `dist/${options.clientProject}/browser`;
|
|
}
|
|
|
|
// In case the browser builder hashes the assets
|
|
// we need to add this setting to the server builder
|
|
// as otherwise when assets it will be requested twice.
|
|
// One for the server which will be unhashed, and other on the client which will be hashed.
|
|
let outputHashing: OutputHashing | undefined;
|
|
if (buildTarget && buildTarget.configurations && buildTarget.configurations.production) {
|
|
switch (buildTarget.configurations.production.outputHashing as OutputHashing) {
|
|
case 'all':
|
|
case 'media':
|
|
outputHashing = 'media';
|
|
break;
|
|
}
|
|
}
|
|
|
|
const mainPath = options.main as string;
|
|
clientProject.targets.add({
|
|
name: 'server',
|
|
builder: Builders.Server,
|
|
options: {
|
|
outputPath: `dist/${options.clientProject}/server`,
|
|
main: join(normalize(clientProject.root), 'src', mainPath.endsWith('.ts') ? mainPath : mainPath + '.ts'),
|
|
tsConfig: join(tsConfigDirectory, `${options.tsconfigFileName}.json`),
|
|
},
|
|
configurations: {
|
|
production: {
|
|
outputHashing,
|
|
fileReplacements,
|
|
sourceMap: false,
|
|
optimization: true,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function findBrowserModuleImport(host: Tree, modulePath: string): ts.Node {
|
|
const moduleBuffer = host.read(modulePath);
|
|
if (!moduleBuffer) {
|
|
throw new SchematicsException(`Module file (${modulePath}) not found`);
|
|
}
|
|
const moduleFileText = moduleBuffer.toString('utf-8');
|
|
|
|
const source = ts.createSourceFile(modulePath, moduleFileText, ts.ScriptTarget.Latest, true);
|
|
|
|
const decoratorMetadata = getDecoratorMetadata(source, 'NgModule', '@angular/core')[0];
|
|
const browserModuleNode = findNode(decoratorMetadata, ts.SyntaxKind.Identifier, 'BrowserModule');
|
|
|
|
if (browserModuleNode === null) {
|
|
throw new SchematicsException(`Cannot find BrowserModule import in ${modulePath}`);
|
|
}
|
|
|
|
return browserModuleNode;
|
|
}
|
|
|
|
function wrapBootstrapCall(mainFile: string): Rule {
|
|
return (host: Tree) => {
|
|
const mainPath = normalize('/' + mainFile);
|
|
let bootstrapCall: ts.Node | null = findBootstrapModuleCall(host, mainPath);
|
|
if (bootstrapCall === null) {
|
|
throw new SchematicsException('Bootstrap module not found.');
|
|
}
|
|
|
|
let bootstrapCallExpression: ts.Node | null = null;
|
|
let currentCall = bootstrapCall;
|
|
while (bootstrapCallExpression === null && currentCall.parent) {
|
|
currentCall = currentCall.parent;
|
|
if (ts.isExpressionStatement(currentCall) || ts.isVariableStatement(currentCall)) {
|
|
bootstrapCallExpression = currentCall;
|
|
}
|
|
}
|
|
bootstrapCall = currentCall;
|
|
|
|
// In case the bootstrap code is a variable statement
|
|
// we need to determine it's usage
|
|
if (bootstrapCallExpression && ts.isVariableStatement(bootstrapCallExpression)) {
|
|
const declaration = bootstrapCallExpression.declarationList.declarations[0];
|
|
const bootstrapVar = (declaration.name as ts.Identifier).text;
|
|
const sf = bootstrapCallExpression.getSourceFile();
|
|
bootstrapCall = findCallExpressionNode(sf, bootstrapVar) || currentCall;
|
|
}
|
|
|
|
// indent contents
|
|
const triviaWidth = bootstrapCall.getLeadingTriviaWidth();
|
|
const beforeText = `document.addEventListener('DOMContentLoaded', () => {\n`
|
|
+ ' '.repeat(triviaWidth > 2 ? triviaWidth + 1 : triviaWidth);
|
|
const afterText = `\n${triviaWidth > 2 ? ' '.repeat(triviaWidth - 1) : ''}});`;
|
|
|
|
// in some cases we need to cater for a trailing semicolon such as;
|
|
// bootstrap().catch(err => console.log(err));
|
|
const lastToken = bootstrapCall.parent.getLastToken();
|
|
let endPos = bootstrapCall.getEnd();
|
|
if (lastToken && lastToken.kind === ts.SyntaxKind.SemicolonToken) {
|
|
endPos = lastToken.getEnd();
|
|
}
|
|
|
|
const recorder = host.beginUpdate(mainPath);
|
|
recorder.insertLeft(bootstrapCall.getStart(), beforeText);
|
|
recorder.insertRight(endPos, afterText);
|
|
host.commitUpdate(recorder);
|
|
};
|
|
}
|
|
|
|
function findCallExpressionNode(node: ts.Node, text: string): ts.Node | null {
|
|
if (
|
|
ts.isCallExpression(node)
|
|
&& ts.isIdentifier(node.expression)
|
|
&& node.expression.text === text
|
|
) {
|
|
return node;
|
|
}
|
|
|
|
let foundNode: ts.Node | null = null;
|
|
ts.forEachChild(node, childNode => {
|
|
foundNode = findCallExpressionNode(childNode, text);
|
|
|
|
if (foundNode) {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
return foundNode;
|
|
}
|
|
|
|
function addServerTransition(
|
|
options: UniversalOptions,
|
|
mainFile: string,
|
|
clientProjectRoot: string,
|
|
): Rule {
|
|
return (host: Tree) => {
|
|
const mainPath = normalize('/' + mainFile);
|
|
|
|
const bootstrapModuleRelativePath = findBootstrapModulePath(host, mainPath);
|
|
const bootstrapModulePath = normalize(
|
|
`/${clientProjectRoot}/src/${bootstrapModuleRelativePath}.ts`);
|
|
|
|
const browserModuleImport = findBrowserModuleImport(host, bootstrapModulePath);
|
|
const appId = options.appId;
|
|
const transitionCall = `.withServerTransition({ appId: '${appId}' })`;
|
|
const position = browserModuleImport.pos + browserModuleImport.getFullText().length;
|
|
const transitionCallChange = new InsertChange(
|
|
bootstrapModulePath, position, transitionCall);
|
|
|
|
const transitionCallRecorder = host.beginUpdate(bootstrapModulePath);
|
|
transitionCallRecorder.insertLeft(transitionCallChange.pos, transitionCallChange.toAdd);
|
|
host.commitUpdate(transitionCallRecorder);
|
|
};
|
|
}
|
|
|
|
function addDependencies(): Rule {
|
|
return (host: Tree) => {
|
|
const coreDep = getPackageJsonDependency(host, '@angular/core');
|
|
if (coreDep === null) {
|
|
throw new SchematicsException('Could not find version.');
|
|
}
|
|
const platformServerDep = {
|
|
...coreDep,
|
|
name: '@angular/platform-server',
|
|
};
|
|
addPackageJsonDependency(host, platformServerDep);
|
|
|
|
return host;
|
|
};
|
|
}
|
|
|
|
function getTsConfigOutDir(host: Tree, tsConfigPath: string): string {
|
|
const tsConfigBuffer = host.read(tsConfigPath);
|
|
if (!tsConfigBuffer) {
|
|
throw new SchematicsException(`Could not read ${tsConfigPath}`);
|
|
}
|
|
const tsConfigContent = tsConfigBuffer.toString();
|
|
const tsConfig = parseJson(tsConfigContent, JsonParseMode.Loose);
|
|
if (tsConfig === null || typeof tsConfig !== 'object' || Array.isArray(tsConfig) ||
|
|
tsConfig.compilerOptions === null || typeof tsConfig.compilerOptions !== 'object' ||
|
|
Array.isArray(tsConfig.compilerOptions)) {
|
|
throw new SchematicsException(`Invalid tsconfig - ${tsConfigPath}`);
|
|
}
|
|
const outDir = tsConfig.compilerOptions.outDir;
|
|
|
|
return outDir as string;
|
|
}
|
|
|
|
export default function (options: UniversalOptions): Rule {
|
|
return async (host: Tree, context: SchematicContext) => {
|
|
const workspace = await getWorkspace(host);
|
|
|
|
const clientProject = workspace.projects.get(options.clientProject);
|
|
if (!clientProject || clientProject.extensions.projectType !== 'application') {
|
|
throw new SchematicsException(`Universal requires a project type of "application".`);
|
|
}
|
|
|
|
const clientBuildTarget = clientProject.targets.get('build');
|
|
if (!clientBuildTarget) {
|
|
throw targetBuildNotFoundError();
|
|
}
|
|
const clientBuildOptions =
|
|
(clientBuildTarget.options || {}) as unknown as BrowserBuilderOptions;
|
|
|
|
const outDir = getTsConfigOutDir(host, clientBuildOptions.tsConfig);
|
|
|
|
const clientTsConfig = normalize(clientBuildOptions.tsConfig);
|
|
const tsConfigExtends = basename(clientTsConfig);
|
|
// this is needed because prior to version 8, tsconfig might have been in 'src'
|
|
// and we don't want to break the 'ng add @nguniversal/express-engine schematics'
|
|
const rootInSrc = clientProject.root === '' && clientTsConfig.includes('src/');
|
|
const tsConfigDirectory = join(normalize(clientProject.root), rootInSrc ? 'src' : '');
|
|
|
|
if (!options.skipInstall) {
|
|
context.addTask(new NodePackageInstallTask());
|
|
}
|
|
|
|
const templateSource = apply(url('./files/src'), [
|
|
applyTemplates({
|
|
...strings,
|
|
...options as object,
|
|
stripTsExtension: (s: string) => s.replace(/\.ts$/, ''),
|
|
hasLocalizePackage: !!getPackageJsonDependency(host, '@angular/localize'),
|
|
}),
|
|
move(join(normalize(clientProject.root), 'src')),
|
|
]);
|
|
|
|
const rootSource = apply(url('./files/root'), [
|
|
applyTemplates({
|
|
...strings,
|
|
...options as object,
|
|
stripTsExtension: (s: string) => s.replace(/\.ts$/, ''),
|
|
outDir,
|
|
tsConfigExtends,
|
|
rootInSrc,
|
|
}),
|
|
move(tsConfigDirectory),
|
|
]);
|
|
|
|
return chain([
|
|
mergeWith(templateSource),
|
|
mergeWith(rootSource),
|
|
addDependencies(),
|
|
updateConfigFile(options, tsConfigDirectory),
|
|
wrapBootstrapCall(clientBuildOptions.main),
|
|
addServerTransition(options, clientBuildOptions.main, clientProject.root),
|
|
]);
|
|
};
|
|
}
|