Doug Parker 160dee33d7 fix(@schematics/angular): don't show server routing prompt when using browser builder
The new routing APIs don't support `browser` builder, but calling `ng add @angular/ssr` with a `browser` builder would still prompt the user to add them. If the user said "Yes", it would actually ignore that answer and not enable the new APIs.

With this change, `ng add @angular/ssr` when using `browser` builder does not show the prompt and assumes the answer is "No". It also throws an error if the user runs `ng add @angular/ssr --server-routing`.

I'm not aware of a built-in prompting mechanism in schematics beyond `x-prompt`, which can't be used here, so instead I just called Inquirer directly. Unfortunately testing the prompt is a little awkward, as Inquirier does not provide useful APIs in this space. I evaluated `@inquirer/testing`, but ultimately decided that was more intended for testing custom Inquirer prompts, not mocking usage of standard prompts. Schematics APIs do not provide a useful way to inject additional data like a mock, so instead I had to do this through a `setPrompterForTestOnly` function. I'm not a huge fan of it, but I don't see a more straightforward way of solving the problem.
2024-11-18 10:37:31 -08:00

460 lines
14 KiB
TypeScript

/**
* @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.dev/license
*/
import { isJsonObject, join, normalize, strings } from '@angular-devkit/core';
import {
Rule,
SchematicContext,
SchematicsException,
Tree,
apply,
applyTemplates,
chain,
mergeWith,
move,
schematic,
url,
} from '@angular-devkit/schematics';
import { posix } from 'node:path';
import { Schema as ServerOptions } from '../server/schema';
import {
DependencyType,
InstallBehavior,
addDependency,
readWorkspace,
updateWorkspace,
} from '../utility';
import { JSONFile } from '../utility/json-file';
import { latestVersions } from '../utility/latest-versions';
import { isStandaloneApp } from '../utility/ng-ast-utils';
import { targetBuildNotFoundError } from '../utility/project-targets';
import { getMainFilePath } from '../utility/standalone/util';
import { ProjectDefinition, getWorkspace } from '../utility/workspace';
import { Builders } from '../utility/workspace-models';
import { Schema as SSROptions } from './schema';
import { isTTY } from './tty';
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 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 architectTarget = project?.targets.get(target);
if (!architectTarget?.options) {
throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
}
const { outputPath } = architectTarget.options;
if (typeof outputPath !== 'string') {
throw new SchematicsException(
`outputPath for ${projectName} ${target} target is not a string.`,
);
}
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 undefined 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';
const pkg = host.readJson(pkgPath) as { scripts?: Record<string, string> } | null;
if (pkg === null) {
throw new SchematicsException('Could not find package.json');
}
if (isUsingApplicationBuilder) {
const { base, server } = await getApplicationBuilderOutputPaths(host, project);
pkg.scripts ??= {};
pkg.scripts[`serve:ssr:${project}`] = `node ${posix.join(base, server)}/server.mjs`;
} else {
const serverDist = await getLegacyOutputPaths(host, project, 'server');
pkg.scripts = {
...pkg.scripts,
'dev:ssr': `ng run ${project}:${SERVE_SSR_TARGET_NAME}`,
'serve:ssr': `node ${serverDist}/main.js`,
'build:ssr': `ng build && ng run ${project}:server`,
'prerender': `ng run ${project}:${PRERENDER_TARGET_NAME}`,
};
}
host.overwrite(pkgPath, JSON.stringify(pkg, null, 2));
};
}
function updateApplicationBuilderTsConfigRule(options: SSROptions): Rule {
return async (host) => {
const workspace = await readWorkspace(host);
const project = workspace.projects.get(options.project);
const buildTarget = project?.targets.get('build');
if (!buildTarget || !buildTarget.options) {
return;
}
const tsConfigPath = buildTarget.options.tsConfig;
if (!tsConfigPath || typeof tsConfigPath !== 'string') {
// No tsconfig path
return;
}
const json = new JSONFile(host, tsConfigPath);
const filesPath = ['files'];
const files = new Set((json.get(filesPath) as string[] | undefined) ?? []);
files.add('src/server.ts');
json.modify(filesPath, [...files]);
};
}
function updateApplicationBuilderWorkspaceConfigRule(
projectSourceRoot: 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,
outputMode: options.serverRouting ? 'server' : undefined,
prerender: options.serverRouting ? undefined : true,
ssr: {
entry: join(normalize(projectSourceRoot), 'server.ts'),
},
};
});
}
function updateWebpackBuilderWorkspaceConfigRule(
projectSourceRoot: string,
options: SSROptions,
): Rule {
return updateWorkspace((workspace) => {
const projectName = options.project;
const project = workspace.projects.get(projectName);
if (!project) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const serverTarget = project.targets.get('server')!;
(serverTarget.options ??= {}).main = posix.join(projectSourceRoot, 'server.ts');
const serveSSRTarget = project.targets.get(SERVE_SSR_TARGET_NAME);
if (serveSSRTarget) {
return;
}
project.targets.add({
name: SERVE_SSR_TARGET_NAME,
builder: '@angular-devkit/build-angular:ssr-dev-server',
defaultConfiguration: 'development',
options: {},
configurations: {
development: {
browserTarget: `${projectName}:build:development`,
serverTarget: `${projectName}:server:development`,
},
production: {
browserTarget: `${projectName}:build:production`,
serverTarget: `${projectName}:server:production`,
},
},
});
const prerenderTarget = project.targets.get(PRERENDER_TARGET_NAME);
if (prerenderTarget) {
return;
}
project.targets.add({
name: PRERENDER_TARGET_NAME,
builder: '@angular-devkit/build-angular:prerender',
defaultConfiguration: 'production',
options: {
routes: ['/'],
},
configurations: {
production: {
browserTarget: `${projectName}:build:production`,
serverTarget: `${projectName}:server:production`,
},
development: {
browserTarget: `${projectName}:build:development`,
serverTarget: `${projectName}:server:development`,
},
},
});
});
}
function updateWebpackBuilderServerTsConfigRule(options: SSROptions): Rule {
return async (host) => {
const workspace = await readWorkspace(host);
const project = workspace.projects.get(options.project);
const serverTarget = project?.targets.get('server');
if (!serverTarget || !serverTarget.options) {
return;
}
const tsConfigPath = serverTarget.options.tsConfig;
if (!tsConfigPath || typeof tsConfigPath !== 'string') {
// No tsconfig path
return;
}
const tsConfig = new JSONFile(host, tsConfigPath);
const filesAstNode = tsConfig.get(['files']);
const serverFilePath = 'src/server.ts';
if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) {
tsConfig.modify(['files'], [...filesAstNode, serverFilePath]);
}
};
}
function addDependencies({ skipInstall }: SSROptions, isUsingApplicationBuilder: boolean): Rule {
const install = skipInstall ? InstallBehavior.None : InstallBehavior.Auto;
const rules: Rule[] = [
addDependency('express', latestVersions['express'], {
type: DependencyType.Default,
install,
}),
addDependency('@types/express', latestVersions['@types/express'], {
type: DependencyType.Dev,
install,
}),
];
if (!isUsingApplicationBuilder) {
rules.push(
addDependency('browser-sync', latestVersions['browser-sync'], {
type: DependencyType.Dev,
install,
}),
);
}
return chain(rules);
}
function addServerFile(
projectSourceRoot: string,
options: ServerOptions,
isStandalone: boolean,
): Rule {
return async (host) => {
const projectName = options.project;
const workspace = await readWorkspace(host);
const project = workspace.projects.get(projectName);
if (!project) {
throw new SchematicsException(`Invalid project name (${projectName})`);
}
const isUsingApplicationBuilder = usingApplicationBuilder(project);
const browserDistDirectory = isUsingApplicationBuilder
? (await getApplicationBuilderOutputPaths(host, projectName)).browser
: await getLegacyOutputPaths(host, projectName, 'build');
const applicationBuilderFiles =
'application-builder' + (options.serverRouting ? '' : '-common-engine');
return mergeWith(
apply(
url(`./files/${isUsingApplicationBuilder ? applicationBuilderFiles : 'server-builder'}`),
[
applyTemplates({
...strings,
...options,
browserDistDirectory,
isStandalone,
}),
move(projectSourceRoot),
],
),
);
};
}
export default function (inputOptions: SSROptions): Rule {
return async (host, context) => {
const browserEntryPoint = await getMainFilePath(host, inputOptions.project);
const isStandalone = isStandaloneApp(host, browserEntryPoint);
const workspace = await getWorkspace(host);
const clientProject = workspace.projects.get(inputOptions.project);
if (!clientProject) {
throw targetBuildNotFoundError();
}
const isUsingApplicationBuilder = usingApplicationBuilder(clientProject);
const serverRouting = await isServerRoutingEnabled(isUsingApplicationBuilder, inputOptions);
const options = { ...inputOptions, serverRouting };
const sourceRoot = clientProject.sourceRoot ?? posix.join(clientProject.root, 'src');
return chain([
schematic('server', {
...options,
skipInstall: true,
}),
...(isUsingApplicationBuilder
? [
updateApplicationBuilderWorkspaceConfigRule(sourceRoot, options, context),
updateApplicationBuilderTsConfigRule(options),
]
: [
updateWebpackBuilderServerTsConfigRule(options),
updateWebpackBuilderWorkspaceConfigRule(sourceRoot, options),
]),
addServerFile(sourceRoot, options, isStandalone),
addScriptsRule(options, isUsingApplicationBuilder),
addDependencies(options, isUsingApplicationBuilder),
]);
};
}
function usingApplicationBuilder(project: ProjectDefinition) {
const buildBuilder = project.targets.get('build')?.builder;
const isUsingApplicationBuilder =
buildBuilder === Builders.Application || buildBuilder === Builders.BuildApplication;
return isUsingApplicationBuilder;
}
// Wrap inquirer in a `prompt` function.
export type Prompt = (message: string, defaultValue: boolean) => Promise<boolean>;
const defaultPrompter: Prompt = async (message, defaultValue) => {
const { confirm } = await import('@inquirer/prompts');
return await confirm({
message,
default: defaultValue,
});
};
// Allow the prompt functionality to be overridden to facilitate testing.
let prompt = defaultPrompter;
export function setPrompterForTestOnly(prompter?: Prompt): void {
prompt = prompter ?? defaultPrompter;
}
/** Returns whether or not server routing is enabled, potentially prompting the user if necessary. */
async function isServerRoutingEnabled(
isUsingApplicationBuilder: boolean,
options: SSROptions,
): Promise<boolean> {
if (!isUsingApplicationBuilder) {
if (options.serverRouting) {
throw new SchematicsException(
'Server routing APIs can only be added to a project using `application` builder.',
);
} else {
return false;
}
}
// Use explicit option if provided.
if (options.serverRouting !== undefined) {
return options.serverRouting;
}
const serverRoutingDefault = false;
// Use the default if not in an interactive terminal.
if (!isTTY()) {
return serverRoutingDefault;
}
// Prompt the user if in an interactive terminal and no option was provided.
return await prompt(
'Would you like to use the Server Routing and App Engine APIs (Developer Preview) for this server application?',
/* defaultValue */ serverRoutingDefault,
);
}