feat(@schematics/angular): update SSR and application builder migration schematics to work with new outputPath

In #26675 we introduced a long-form variant of `outputPath`, this commit updates the application builder migration and ssr schematics to handle this change.
This commit is contained in:
Alan Agius 2023-12-18 09:45:18 +00:00 committed by Alan Agius
parent 69d2dfdb50
commit a708dccff3
5 changed files with 275 additions and 33 deletions

View File

@ -14,7 +14,7 @@ import {
chain,
externalSchematic,
} from '@angular-devkit/schematics';
import { dirname } from 'node:path';
import { dirname, join } from 'node:path/posix';
import { JSONFile } from '../../utility/json-file';
import { TreeWorkspaceHost, allTargetOptions, getWorkspace } from '../../utility/workspace';
import { Builders, ProjectType } from '../../utility/workspace-models';
@ -68,8 +68,33 @@ export default function (): Rule {
options['polyfills'] = [options['polyfills']];
}
if (typeof options['outputPath'] === 'string') {
options['outputPath'] = options['outputPath']?.replace(/\/browser\/?$/, '');
let outputPath = options['outputPath'];
if (typeof outputPath === 'string') {
if (!/\/browser\/?$/.test(outputPath)) {
// TODO: add prompt.
context.logger.warn(
`The output location of the browser build has been updated from "${outputPath}" to ` +
`"${join(outputPath, 'browser')}". ` +
'You might need to adjust your deployment pipeline or, as an alternative, ' +
'set outputPath.browser to "" in order to maintain the previous functionality.',
);
} else {
outputPath = outputPath.replace(/\/browser\/?$/, '');
}
options['outputPath'] = {
base: outputPath,
};
if (typeof options['resourcesOutputPath'] === 'string') {
const media = options['resourcesOutputPath'].replaceAll('/', '');
if (media && media !== 'media') {
options['outputPath'] = {
base: outputPath,
media: media,
};
}
}
}
// Delete removed options
@ -189,13 +214,5 @@ function usesNoLongerSupportedOptions(
);
}
if (typeof resourcesOutputPath === 'string' && /^\/?media\/?$/.test(resourcesOutputPath)) {
hasUsage = true;
context.logger.warn(
`Skipping migration for project "${projectName}". "resourcesOutputPath" option is not available in the application builder.` +
`Media files will be output into a "media" directory within the output location.`,
);
}
return hasUsage;
}

View File

@ -0,0 +1,104 @@
/**
* @license
* Copyright Google LLC 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 { EmptyTree } from '@angular-devkit/schematics';
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import { Builders, ProjectType, WorkspaceSchema } from '../../utility/workspace-models';
function createWorkSpaceConfig(tree: UnitTestTree) {
const angularConfig: WorkspaceSchema = {
version: 1,
projects: {
app: {
root: '/project/lib',
sourceRoot: '/project/app/src',
projectType: ProjectType.Application,
prefix: 'app',
architect: {
build: {
builder: Builders.Browser,
options: {
tsConfig: 'src/tsconfig.app.json',
main: 'src/main.ts',
polyfills: 'src/polyfills.ts',
outputPath: 'dist/project',
resourcesOutputPath: '/resources',
},
},
},
},
},
};
tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2));
tree.create('/tsconfig.json', JSON.stringify({}, undefined, 2));
tree.create('/package.json', JSON.stringify({}, undefined, 2));
}
describe(`Migration to use the application builder`, () => {
const schematicName = 'use-application-builder';
const schematicRunner = new SchematicTestRunner(
'migrations',
require.resolve('../migration-collection.json'),
);
let tree: UnitTestTree;
beforeEach(() => {
tree = new UnitTestTree(new EmptyTree());
createWorkSpaceConfig(tree);
});
it(`should replace 'outputPath' to string if 'resourcesOutputPath' is set to 'media'`, async () => {
// Replace resourcesOutputPath
tree.overwrite('angular.json', tree.readContent('angular.json').replace('/resources', 'media'));
const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
const {
projects: { app },
} = JSON.parse(newTree.readContent('/angular.json'));
const { outputPath, resourcesOutputPath } = app.architect['build'].options;
expect(outputPath).toEqual({
base: 'dist/project',
});
expect(resourcesOutputPath).toBeUndefined();
});
it(`should set 'outputPath.media' if 'resourcesOutputPath' is set and is not 'media'`, async () => {
const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
const {
projects: { app },
} = JSON.parse(newTree.readContent('/angular.json'));
const { outputPath, resourcesOutputPath } = app.architect['build'].options;
expect(outputPath).toEqual({
base: 'dist/project',
media: 'resources',
});
expect(resourcesOutputPath).toBeUndefined();
});
it(`should remove 'browser' portion from 'outputPath'`, async () => {
// Replace outputPath
tree.overwrite(
'angular.json',
tree.readContent('angular.json').replace('dist/project/', 'dist/project/browser/'),
);
const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
const {
projects: { app },
} = JSON.parse(newTree.readContent('/angular.json'));
const { outputPath } = app.architect['build'].options;
expect(outputPath).toEqual({
base: 'dist/project',
media: 'resources',
});
});
});

View File

@ -9,7 +9,7 @@ import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> fr
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const browserDistFolder = resolve(serverDistFolder, '../<%= browserDistDirectory %>');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
@ -19,7 +19,7 @@ export function app(): express.Express {
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
// Serve static files from /<%= browserDistDirectory %>
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y'
}));

View File

@ -6,9 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
import { join, normalize, strings } from '@angular-devkit/core';
import { isJsonObject, join, normalize, strings } from '@angular-devkit/core';
import {
Rule,
SchematicContext,
SchematicsException,
Tree,
apply,
@ -19,6 +20,7 @@ import {
schematic,
url,
} from '@angular-devkit/schematics';
import { posix } from 'node:path';
import { Schema as ServerOptions } from '../server/schema';
import { DependencyType, addDependency, readWorkspace, updateWorkspace } from '../utility';
import { JSONFile } from '../utility/json-file';
@ -33,8 +35,11 @@ import { Schema as SSROptions } from './schema';
const SERVE_SSR_TARGET_NAME = 'serve-ssr';
const PRERENDER_TARGET_NAME = 'prerender';
const DEFAULT_BROWSER_DIR = 'browser';
const DEFAULT_MEDIA_DIR = 'media';
const DEFAULT_SERVER_DIR = 'server';
async function getOutputPath(
async function getLegacyOutputPaths(
host: Tree,
projectName: string,
target: 'server' | 'build',
@ -42,12 +47,12 @@ async function getOutputPath(
// Generate new output paths
const workspace = await readWorkspace(host);
const project = workspace.projects.get(projectName);
const serverTarget = project?.targets.get(target);
if (!serverTarget || !serverTarget.options) {
const architectTarget = project?.targets.get(target);
if (!architectTarget?.options) {
throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
}
const { outputPath } = serverTarget.options;
const { outputPath } = architectTarget.options;
if (typeof outputPath !== 'string') {
throw new SchematicsException(
`outputPath for ${projectName} ${target} target is not a string.`,
@ -57,6 +62,52 @@ async function getOutputPath(
return outputPath;
}
async function getApplicationBuilderOutputPaths(
host: Tree,
projectName: string,
): Promise<{ browser: string; server: string; base: string }> {
// Generate new output paths
const target = 'build';
const workspace = await readWorkspace(host);
const project = workspace.projects.get(projectName);
const architectTarget = project?.targets.get(target);
if (!architectTarget?.options) {
throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
}
const { outputPath } = architectTarget.options;
if (outputPath === null || outputPath === undefined) {
throw new SchematicsException(
`outputPath for ${projectName} ${target} target is undeined or null.`,
);
}
const defaultDirs = {
server: DEFAULT_SERVER_DIR,
browser: DEFAULT_BROWSER_DIR,
};
if (outputPath && isJsonObject(outputPath)) {
return {
...defaultDirs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(outputPath as any),
};
}
if (typeof outputPath !== 'string') {
throw new SchematicsException(
`outputPath for ${projectName} ${target} target is not a string.`,
);
}
return {
base: outputPath,
...defaultDirs,
};
}
function addScriptsRule({ project }: SSROptions, isUsingApplicationBuilder: boolean): Rule {
return async (host) => {
const pkgPath = '/package.json';
@ -66,11 +117,11 @@ function addScriptsRule({ project }: SSROptions, isUsingApplicationBuilder: bool
}
if (isUsingApplicationBuilder) {
const distPath = await getOutputPath(host, project, 'build');
const { base, server } = await getApplicationBuilderOutputPaths(host, project);
pkg.scripts ??= {};
pkg.scripts[`serve:ssr:${project}`] = `node ${distPath}/server/server.mjs`;
pkg.scripts[`serve:ssr:${project}`] = `node ${posix.join(base, server)}/server.mjs`;
} else {
const serverDist = await getOutputPath(host, project, 'server');
const serverDist = await getLegacyOutputPaths(host, project, 'server');
pkg.scripts = {
...pkg.scripts,
'dev:ssr': `ng run ${project}:${SERVE_SSR_TARGET_NAME}`,
@ -111,6 +162,7 @@ function updateApplicationBuilderTsConfigRule(options: SSROptions): Rule {
function updateApplicationBuilderWorkspaceConfigRule(
projectRoot: string,
options: SSROptions,
{ logger }: SchematicContext,
): Rule {
return updateWorkspace((workspace) => {
const buildTarget = workspace.projects.get(options.project)?.targets.get('build');
@ -118,8 +170,32 @@ function updateApplicationBuilderWorkspaceConfigRule(
return;
}
let outputPath = buildTarget.options?.outputPath;
if (outputPath && isJsonObject(outputPath)) {
if (outputPath.browser === '') {
const base = outputPath.base as string;
logger.warn(
`The output location of the browser build has been updated from "${base}" to "${posix.join(
base,
DEFAULT_BROWSER_DIR,
)}".
You might need to adjust your deployment pipeline.`,
);
if (
(outputPath.media && outputPath.media !== DEFAULT_MEDIA_DIR) ||
(outputPath.server && outputPath.server !== DEFAULT_SERVER_DIR)
) {
delete outputPath.browser;
} else {
outputPath = outputPath.base;
}
}
}
buildTarget.options = {
...buildTarget.options,
outputPath,
prerender: true,
ssr: {
entry: join(normalize(projectRoot), 'server.ts'),
@ -238,23 +314,22 @@ function addDependencies(isUsingApplicationBuilder: boolean): Rule {
function addServerFile(options: ServerOptions, isStandalone: boolean): Rule {
return async (host) => {
const projectName = options.project;
const workspace = await readWorkspace(host);
const project = workspace.projects.get(options.project);
const project = workspace.projects.get(projectName);
if (!project) {
throw new SchematicsException(`Invalid project name (${options.project})`);
throw new SchematicsException(`Invalid project name (${projectName})`);
}
const isUsingApplicationBuilder =
project?.targets?.get('build')?.builder === Builders.Application;
const browserDistDirectory = await getOutputPath(host, options.project, 'build');
const browserDistDirectory = isUsingApplicationBuilder
? (await getApplicationBuilderOutputPaths(host, projectName)).browser
: await getLegacyOutputPaths(host, projectName, 'build');
return mergeWith(
apply(
url(
`./files/${
project?.targets?.get('build')?.builder === Builders.Application
? 'application-builder'
: 'server-builder'
}`,
),
url(`./files/${isUsingApplicationBuilder ? 'application-builder' : 'server-builder'}`),
[
applyTemplates({
...strings,
@ -270,7 +345,7 @@ function addServerFile(options: ServerOptions, isStandalone: boolean): Rule {
}
export default function (options: SSROptions): Rule {
return async (host) => {
return async (host, context) => {
const browserEntryPoint = await getMainFilePath(host, options.project);
const isStandalone = isStandaloneApp(host, browserEntryPoint);
@ -289,7 +364,7 @@ export default function (options: SSROptions): Rule {
}),
...(isUsingApplicationBuilder
? [
updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options),
updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options, context),
updateApplicationBuilderTsConfigRule(options),
]
: [

View File

@ -143,6 +143,52 @@ describe('SSR Schematic', () => {
expect(scripts['serve:ssr:test-app']).toBe(`node dist/test-app/server/server.mjs`);
});
it('works when using a custom "outputPath.browser" and "outputPath.server" values', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config = appTree.readJson('/angular.json') as any;
const build = config.projects['test-app'].architect.build;
build.options.outputPath = {
base: build.options.outputPath,
browser: 'public',
server: 'node-server',
};
appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2));
const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree);
const { scripts } = tree.readJson('/package.json') as { scripts: Record<string, string> };
expect(scripts['serve:ssr:test-app']).toBe(`node dist/test-app/node-server/server.mjs`);
const serverFileContent = tree.readContent('/projects/test-app/server.ts');
expect(serverFileContent).toContain(`resolve(serverDistFolder, '../public')`);
});
it(`removes "outputPath.browser" when it's an empty string`, async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config = appTree.readJson('/angular.json') as any;
const build = config.projects['test-app'].architect.build;
build.options.outputPath = {
base: build.options.outputPath,
browser: '',
server: 'node-server',
};
appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2));
const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree);
const { scripts } = tree.readJson('/package.json') as { scripts: Record<string, string> };
expect(scripts['serve:ssr:test-app']).toBe(`node dist/test-app/node-server/server.mjs`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updatedConfig = tree.readJson('/angular.json') as any;
expect(updatedConfig.projects['test-app'].architect.build.options.outputPath).toEqual({
base: 'dist/test-app',
server: 'node-server',
});
});
});
describe('Legacy browser builder', () => {