Doug Parker 173dc0eeac fix(@schematics/angular): skip SSR routing prompt in webcontainer
Apparently `inquirer` requires `async_hooks` which isn't supported in webcontainers, therefore prompting the user fails. Instead we always fall back to the default option.

See: https://github.com/SBoudrias/Inquirer.js/issues/1426
2024-11-25 15:02:55 -05:00

466 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;
}
// `inquirer` requires `async_hooks` which isn't supported by webcontainers, therefore we can't prompt in that context.
// See: https://github.com/SBoudrias/Inquirer.js/issues/1426
if (process.versions.webcontainer) {
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,
);
}