diff --git a/packages/schematics/angular/migrations/update-17/use-application-builder.ts b/packages/schematics/angular/migrations/update-17/use-application-builder.ts index d30dadcd54ee..2ba1a13bf010 100644 --- a/packages/schematics/angular/migrations/update-17/use-application-builder.ts +++ b/packages/schematics/angular/migrations/update-17/use-application-builder.ts @@ -14,7 +14,7 @@ import { chain, externalSchematic, } from '@angular-devkit/schematics'; -import { dirname } from 'node:path'; +import { posix } from 'node:path'; import { JSONFile } from '../../utility/json-file'; import { TreeWorkspaceHost, allTargetOptions, getWorkspace } from '../../utility/workspace'; import { Builders, ProjectType } from '../../utility/workspace-models'; @@ -60,7 +60,7 @@ export default function (): Rule { // Rename and transform options options['browser'] = options['main']; if (hasServerTarget && typeof options['browser'] === 'string') { - options['server'] = dirname(options['browser']) + '/main.server.ts'; + options['server'] = posix.dirname(options['browser']) + '/main.server.ts'; } options['serviceWorker'] = options['ngswConfigPath'] ?? options['serviceWorker']; @@ -68,8 +68,30 @@ 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 have been updated from "${outputPath}" to ` + + `"${posix.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'] = 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 +211,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; } diff --git a/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts b/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts new file mode 100644 index 000000000000..933a7a7d8825 --- /dev/null +++ b/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts @@ -0,0 +1,102 @@ +/** + * @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).toBe('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 renove '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', + }); + }); +}); diff --git a/packages/schematics/angular/ssr/files/application-builder/server.ts.template b/packages/schematics/angular/ssr/files/application-builder/server.ts.template index 6a958ae87962..7bf10181c7d1 100644 --- a/packages/schematics/angular/ssr/files/application-builder/server.ts.template +++ b/packages/schematics/angular/ssr/files/application-builder/server.ts.template @@ -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' })); diff --git a/packages/schematics/angular/ssr/index.ts b/packages/schematics/angular/ssr/index.ts index 8c0436685e2c..8db4d95069e2 100644 --- a/packages/schematics/angular/ssr/index.ts +++ b/packages/schematics/angular/ssr/index.ts @@ -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 have 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), ] : [ diff --git a/packages/schematics/angular/ssr/index_spec.ts b/packages/schematics/angular/ssr/index_spec.ts index 03d53215f0fc..2ae671b537c6 100644 --- a/packages/schematics/angular/ssr/index_spec.ts +++ b/packages/schematics/angular/ssr/index_spec.ts @@ -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 }; + 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 }; + 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', () => {