Skip to content

Commit

Permalink
feat(@schematics/angular): update SSR and application builder migrati…
Browse files Browse the repository at this point in the history
…on 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.
  • Loading branch information
alan-agius4 committed Dec 19, 2023
1 parent 69d2dfd commit a708dcc
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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'
}));
Expand Down
115 changes: 95 additions & 20 deletions packages/schematics/angular/ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -33,21 +35,24 @@ 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',
): Promise<string> {
// 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.`,
Expand All @@ -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';
Expand All @@ -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}`,
Expand Down Expand Up @@ -111,15 +162,40 @@ 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');
if (!buildTarget) {
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'),
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -289,7 +364,7 @@ export default function (options: SSROptions): Rule {
}),
...(isUsingApplicationBuilder
? [
updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options),
updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options, context),
updateApplicationBuilderTsConfigRule(options),
]
: [
Expand Down

0 comments on commit a708dcc

Please sign in to comment.