Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading