From 6a85b13b1f6a9a459977ed7e5854284296605417 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 25 Sep 2023 07:24:21 +0000 Subject: [PATCH] refactor(@angular/ssr): move `ng-add` schematic to `@schematics/angular` This move is in preparation to enable `ng new --ssr`. --- packages/angular/ssr/schematics/BUILD.bazel | 10 - .../angular/ssr/schematics/collection.json | 2 +- .../angular/ssr/schematics/ng-add/index.ts | 250 +-------------- .../ssr/schematics/ng-add/index_spec.ts | 186 ++---------- .../angular/ssr/schematics/ng-add/schema.json | 2 +- .../utility/latest-versions/index.ts | 13 - .../utility/latest-versions/package.json | 9 - .../angular/ssr/schematics/utility/utils.ts | 47 --- packages/schematics/angular/collection.json | 6 + .../application-builder/server.ts.template | 0 .../files/server-builder/server.ts.template | 0 packages/schematics/angular/ssr/index.ts | 287 ++++++++++++++++++ packages/schematics/angular/ssr/index_spec.ts | 206 +++++++++++++ packages/schematics/angular/ssr/schema.json | 22 ++ .../utility/latest-versions/package.json | 1 + renovate.json | 6 +- 16 files changed, 550 insertions(+), 497 deletions(-) delete mode 100644 packages/angular/ssr/schematics/utility/latest-versions/index.ts delete mode 100644 packages/angular/ssr/schematics/utility/latest-versions/package.json delete mode 100644 packages/angular/ssr/schematics/utility/utils.ts rename packages/{angular/ssr/schematics/ng-add => schematics/angular/ssr}/files/application-builder/server.ts.template (100%) rename packages/{angular/ssr/schematics/ng-add => schematics/angular/ssr}/files/server-builder/server.ts.template (100%) create mode 100644 packages/schematics/angular/ssr/index.ts create mode 100644 packages/schematics/angular/ssr/index_spec.ts create mode 100644 packages/schematics/angular/ssr/schema.json diff --git a/packages/angular/ssr/schematics/BUILD.bazel b/packages/angular/ssr/schematics/BUILD.bazel index e1d1746a0992..bd7fb6b43ebd 100644 --- a/packages/angular/ssr/schematics/BUILD.bazel +++ b/packages/angular/ssr/schematics/BUILD.bazel @@ -40,13 +40,8 @@ filegroup( name = "schematics_assets", srcs = glob( [ - "**/files/**/*", "**/*.json", ], - exclude = [ - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", - ], ), ) @@ -66,11 +61,8 @@ ts_library( ], data = [":schematics_assets"], deps = [ - "//packages/angular_devkit/core", "//packages/angular_devkit/schematics", "//packages/schematics/angular", - "@npm//@types/node", - "@npm//typescript", ], ) @@ -89,8 +81,6 @@ ts_library( # @external_begin deps = [ ":schematics", - "//packages/angular_devkit/core", - "//packages/angular_devkit/schematics", "//packages/angular_devkit/schematics/testing", ], # @external_end diff --git a/packages/angular/ssr/schematics/collection.json b/packages/angular/ssr/schematics/collection.json index 8b458eae76fa..5a3fa1f0e5e3 100644 --- a/packages/angular/ssr/schematics/collection.json +++ b/packages/angular/ssr/schematics/collection.json @@ -2,7 +2,7 @@ "schematics": { "ng-add": { "description": "Adds Angular SSR to the application without affecting any templates", - "factory": "./ng-add", + "factory": "./ng-add/index", "schema": "./ng-add/schema.json" } } diff --git a/packages/angular/ssr/schematics/ng-add/index.ts b/packages/angular/ssr/schematics/ng-add/index.ts index 7acdee356b71..f57fabcea5e3 100644 --- a/packages/angular/ssr/schematics/ng-add/index.ts +++ b/packages/angular/ssr/schematics/ng-add/index.ts @@ -6,251 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import { join, normalize, strings } from '@angular-devkit/core'; -import { - Rule, - SchematicsException, - apply, - applyTemplates, - chain, - externalSchematic, - mergeWith, - move, - noop, - url, -} from '@angular-devkit/schematics'; -import { Schema as ServerOptions } from '@schematics/angular/server/schema'; -import { DependencyType, addDependency, updateWorkspace } from '@schematics/angular/utility'; -import { JSONFile } from '@schematics/angular/utility/json-file'; -import { isStandaloneApp } from '@schematics/angular/utility/ng-ast-utils'; -import { targetBuildNotFoundError } from '@schematics/angular/utility/project-targets'; -import { getMainFilePath } from '@schematics/angular/utility/standalone/util'; -import { getWorkspace } from '@schematics/angular/utility/workspace'; -import { Builders } from '@schematics/angular/utility/workspace-models'; +import { Rule, externalSchematic } from '@angular-devkit/schematics'; +import { Schema as SSROptions } from './schema'; -import { latestVersions } from '../utility/latest-versions'; -import { getOutputPath, getProject } from '../utility/utils'; - -import { Schema as AddServerOptions } from './schema'; - -const SERVE_SSR_TARGET_NAME = 'serve-ssr'; -const PRERENDER_TARGET_NAME = 'prerender'; - -function addScriptsRule(options: AddServerOptions): Rule { - return async (host) => { - const pkgPath = '/package.json'; - const buffer = host.read(pkgPath); - if (buffer === null) { - throw new SchematicsException('Could not find package.json'); - } - - const serverDist = await getOutputPath(host, options.project, 'server'); - const pkg = JSON.parse(buffer.toString()) as { scripts?: Record }; - pkg.scripts = { - ...pkg.scripts, - 'dev:ssr': `ng run ${options.project}:${SERVE_SSR_TARGET_NAME}`, - 'serve:ssr': `node ${serverDist}/main.js`, - 'build:ssr': `ng build && ng run ${options.project}:server`, - 'prerender': `ng run ${options.project}:${PRERENDER_TARGET_NAME}`, - }; - - host.overwrite(pkgPath, JSON.stringify(pkg, null, 2)); - }; -} - -function updateApplicationBuilderTsConfigRule(options: AddServerOptions): Rule { - return async (host) => { - const project = await getProject(host, 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 tsConfig = new JSONFile(host, tsConfigPath); - const filesAstNode = tsConfig.get(['files']); - const serverFilePath = 'server.ts'; - if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) { - tsConfig.modify(['files'], [...filesAstNode, serverFilePath]); - } - }; -} - -function updateApplicationBuilderWorkspaceConfigRule( - projectRoot: string, - options: AddServerOptions, -): Rule { - return updateWorkspace((workspace) => { - const buildTarget = workspace.projects.get(options.project)?.targets.get('build'); - if (!buildTarget) { - return; - } - - buildTarget.options = { - ...buildTarget.options, - prerender: true, - ssr: join(normalize(projectRoot), 'server.ts'), - }; - }); -} - -function updateWebpackBuilderWorkspaceConfigRule(options: AddServerOptions): 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 = join(normalize(project.root), '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: AddServerOptions): Rule { - return async (host) => { - const project = await getProject(host, 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 = 'server.ts'; - if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) { - tsConfig.modify(['files'], [...filesAstNode, serverFilePath]); - } - }; -} - -function addDependencies(): Rule { - return chain([ - addDependency('express', latestVersions['express'], { - type: DependencyType.Default, - }), - addDependency('@types/express', latestVersions['@types/express'], { - type: DependencyType.Dev, - }), - ]); -} - -function addServerFile(options: ServerOptions, isStandalone: boolean): Rule { - return async (host) => { - const project = await getProject(host, options.project); - const browserDistDirectory = await getOutputPath(host, options.project, 'build'); - - return mergeWith( - apply( - url( - `./files/${ - project?.targets?.get('build')?.builder === Builders.Application - ? 'application-builder' - : 'server-builder' - }`, - ), - [ - applyTemplates({ - ...strings, - ...options, - browserDistDirectory, - isStandalone, - }), - move(project.root), - ], - ), - ); - }; -} - -export default function (options: AddServerOptions): Rule { - return async (host) => { - const browserEntryPoint = await getMainFilePath(host, options.project); - const isStandalone = isStandaloneApp(host, browserEntryPoint); - - const workspace = await getWorkspace(host); - const clientProject = workspace.projects.get(options.project); - if (!clientProject) { - throw targetBuildNotFoundError(); - } - const isUsingApplicationBuilder = - clientProject.targets.get('build')?.builder === Builders.Application; - - return chain([ - externalSchematic('@schematics/angular', 'server', { - ...options, - skipInstall: true, - }), - ...(isUsingApplicationBuilder - ? [ - updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options), - updateApplicationBuilderTsConfigRule(options), - ] - : [ - addScriptsRule(options), - updateWebpackBuilderServerTsConfigRule(options), - updateWebpackBuilderWorkspaceConfigRule(options), - ]), - addServerFile(options, isStandalone), - addDependencies(), - ]); - }; +export default function (options: SSROptions): Rule { + return externalSchematic('@schematics/angular', 'ssr', options); } diff --git a/packages/angular/ssr/schematics/ng-add/index_spec.ts b/packages/angular/ssr/schematics/ng-add/index_spec.ts index cb66e4360a9d..c8840c8eedff 100644 --- a/packages/angular/ssr/schematics/ng-add/index_spec.ts +++ b/packages/angular/ssr/schematics/ng-add/index_spec.ts @@ -6,15 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import { tags } from '@angular-devkit/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; import { join } from 'node:path'; -import { Schema as ServerOptions } from './schema'; describe('SSR Schematic', () => { - const defaultOptions: ServerOptions = { + const defaultOptions = { project: 'test-app', }; @@ -37,171 +35,27 @@ describe('SSR Schematic', () => { 'workspace', workspaceOptions, ); + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + { + name: 'test-app', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: 'css', + skipTests: false, + standalone: true, + }, + appTree, + ); }); - describe('non standalone application', () => { - beforeEach(async () => { - appTree = await schematicRunner.runExternalSchematic( - '@schematics/angular', - 'application', - { - name: 'test-app', - inlineStyle: false, - inlineTemplate: false, - routing: false, - style: 'css', - skipTests: false, - standalone: false, - }, - appTree, - ); - }); - - it('should add dependency: express', async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const filePath = '/package.json'; - const contents = tree.readContent(filePath); - expect(contents).toContain('express'); - }); - - it('should install npm dependencies', async () => { - await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - expect(schematicRunner.tasks.length).toBe(1); - expect(schematicRunner.tasks[0].name).toBe('node-package'); - expect((schematicRunner.tasks[0].options as { command: string }).command).toBe('install'); - }); - - it(`should update 'tsconfig.app.json' files with Express main file`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - const { files } = tree.readJson('/projects/test-app/tsconfig.app.json') as { - files: string[]; - }; - - expect(files).toEqual(['src/main.ts', 'src/main.server.ts', 'server.ts']); - }); - - it(`should import 'AppServerModule' from 'main.server.ts'`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const filePath = '/projects/test-app/server.ts'; - const content = tree.readContent(filePath); - expect(content).toContain(`import AppServerModule from './src/main.server';`); - }); - - it(`should pass 'AppServerModule' in the bootstrap parameter.`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const filePath = '/projects/test-app/server.ts'; - const content = tree.readContent(filePath); - expect(tags.oneLine`${content}`).toContain(tags.oneLine` - .render({ - bootstrap: AppServerModule, - `); - }); - }); - - describe('standalone application', () => { - beforeEach(async () => { - appTree = await schematicRunner.runExternalSchematic( - '@schematics/angular', - 'application', - { - name: 'test-app', - inlineStyle: false, - inlineTemplate: false, - routing: false, - style: 'css', - skipTests: false, - standalone: true, - }, - appTree, - ); - }); - - it(`should add default import to 'main.server.ts'`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const filePath = '/projects/test-app/server.ts'; - const content = tree.readContent(filePath); - expect(content).toContain(`import bootstrap from './src/main.server';`); - }); - - it(`should pass 'AppServerModule' in the bootstrap parameter.`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const filePath = '/projects/test-app/server.ts'; - const content = tree.readContent(filePath); - expect(tags.oneLine`${content}`).toContain(tags.oneLine` - .render({ - bootstrap, - `); - }); - }); - - describe('Legacy browser builder', () => { - function convertBuilderToLegacyBrowser(): void { - const config = JSON.parse(appTree.readContent('/angular.json')); - const build = config.projects['test-app'].architect.build; - - build.builder = '@angular-devkit/build-angular:browser'; - build.options = { - ...build.options, - main: build.options.browser, - browser: undefined, - }; - - build.configurations.development = { - ...build.configurations.development, - vendorChunk: true, - namedChunks: true, - buildOptimizer: false, - }; - - appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2)); - } - - beforeEach(async () => { - appTree = await schematicRunner.runExternalSchematic( - '@schematics/angular', - 'application', - { - name: 'test-app', - inlineStyle: false, - inlineTemplate: false, - routing: false, - style: 'css', - skipTests: false, - standalone: false, - }, - appTree, - ); - - convertBuilderToLegacyBrowser(); - }); - - it(`should update 'tsconfig.server.json' files with Express main file`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const { files } = tree.readJson('/projects/test-app/tsconfig.server.json') as { - files: string[]; - }; - - expect(files).toEqual(['src/main.server.ts', 'server.ts']); - }); - - it(`should add export to main file in 'server.ts'`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - - const content = tree.readContent('/projects/test-app/server.ts'); - expect(content).toContain(`export default AppServerModule`); - }); - - it(`should add correct value to 'distFolder'`, async () => { - const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + it('works', async () => { + const filePath = '/projects/test-app/server.ts'; - const content = tree.readContent('/projects/test-app/server.ts'); - expect(content).toContain(`const distFolder = join(process.cwd(), 'dist/test-app/browser');`); - }); + expect(appTree.exists(filePath)).toBeFalse(); + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + expect(tree.exists(filePath)).toBeTrue(); }); }); diff --git a/packages/angular/ssr/schematics/ng-add/schema.json b/packages/angular/ssr/schematics/ng-add/schema.json index b239a9e92168..339ad179ad8e 100644 --- a/packages/angular/ssr/schematics/ng-add/schema.json +++ b/packages/angular/ssr/schematics/ng-add/schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "SchematicsAngularSSRAdd", + "$id": "SchematicsAngularNgAddSSR", "title": "Angular SSR Options Schema", "type": "object", "properties": { diff --git a/packages/angular/ssr/schematics/utility/latest-versions/index.ts b/packages/angular/ssr/schematics/utility/latest-versions/index.ts deleted file mode 100644 index 03509acb15db..000000000000 --- a/packages/angular/ssr/schematics/utility/latest-versions/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @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 - */ - -export const latestVersions: Record = { - // We could have used TypeScripts' `resolveJsonModule` to make the `latestVersion` object typesafe, - // but ts_library doesn't support JSON inputs. - ...require('./package.json')['dependencies'], -}; diff --git a/packages/angular/ssr/schematics/utility/latest-versions/package.json b/packages/angular/ssr/schematics/utility/latest-versions/package.json deleted file mode 100644 index 54a8eb5a5603..000000000000 --- a/packages/angular/ssr/schematics/utility/latest-versions/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "description": "Package versions used by schematics in @angular/ssr.", - "comment": "This file is needed so that dependencies are synced by Renovate.", - "private": true, - "dependencies": { - "@types/express": "^4.17.17", - "express": "^4.18.2" - } -} diff --git a/packages/angular/ssr/schematics/utility/utils.ts b/packages/angular/ssr/schematics/utility/utils.ts deleted file mode 100644 index 1c30a9cc6a5e..000000000000 --- a/packages/angular/ssr/schematics/utility/utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @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 { workspaces } from '@angular-devkit/core'; -import { SchematicsException, Tree } from '@angular-devkit/schematics'; -import { readWorkspace } from '@schematics/angular/utility'; - -export async function getProject( - host: Tree, - projectName: string, -): Promise { - const workspace = await readWorkspace(host); - const project = workspace.projects.get(projectName); - - if (!project || project.extensions.projectType !== 'application') { - throw new SchematicsException(`Universal requires a project type of 'application'.`); - } - - return project; -} - -export async function getOutputPath( - host: Tree, - projectName: string, - target: 'server' | 'build', -): Promise { - // Generate new output paths - const project = await getProject(host, projectName); - const serverTarget = project.targets.get(target); - if (!serverTarget || !serverTarget.options) { - throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`); - } - - const { outputPath } = serverTarget.options; - if (typeof outputPath !== 'string') { - throw new SchematicsException( - `outputPath for ${projectName} ${target} target is not a string.`, - ); - } - - return outputPath; -} diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 118a89623bf9..f4def2da5a43 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -100,6 +100,12 @@ "schema": "./server/schema.json", "hidden": true }, + "ssr": { + "factory": "./ssr", + "description": "Adds SSR to an Angular app.", + "schema": "./ssr/schema.json", + "hidden": true + }, "app-shell": { "factory": "./app-shell", "description": "Create an application shell.", diff --git a/packages/angular/ssr/schematics/ng-add/files/application-builder/server.ts.template b/packages/schematics/angular/ssr/files/application-builder/server.ts.template similarity index 100% rename from packages/angular/ssr/schematics/ng-add/files/application-builder/server.ts.template rename to packages/schematics/angular/ssr/files/application-builder/server.ts.template diff --git a/packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template b/packages/schematics/angular/ssr/files/server-builder/server.ts.template similarity index 100% rename from packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template rename to packages/schematics/angular/ssr/files/server-builder/server.ts.template diff --git a/packages/schematics/angular/ssr/index.ts b/packages/schematics/angular/ssr/index.ts new file mode 100644 index 000000000000..e88a6fd14771 --- /dev/null +++ b/packages/schematics/angular/ssr/index.ts @@ -0,0 +1,287 @@ +/** + * @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 { join, normalize, strings } from '@angular-devkit/core'; +import { + Rule, + SchematicsException, + Tree, + apply, + applyTemplates, + chain, + externalSchematic, + mergeWith, + move, + url, +} from '@angular-devkit/schematics'; +import { Schema as ServerOptions } from '../server/schema'; +import { DependencyType, 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 { getWorkspace } from '../utility/workspace'; +import { Builders } from '../utility/workspace-models'; + +import { Schema as SSROptions } from './schema'; + +const SERVE_SSR_TARGET_NAME = 'serve-ssr'; +const PRERENDER_TARGET_NAME = 'prerender'; + +async function getOutputPath( + host: Tree, + projectName: string, + target: 'server' | 'build', +): Promise { + // 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) { + throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`); + } + + const { outputPath } = serverTarget.options; + if (typeof outputPath !== 'string') { + throw new SchematicsException( + `outputPath for ${projectName} ${target} target is not a string.`, + ); + } + + return outputPath; +} + +function addScriptsRule(options: SSROptions): Rule { + return async (host) => { + const pkgPath = '/package.json'; + const buffer = host.read(pkgPath); + if (buffer === null) { + throw new SchematicsException('Could not find package.json'); + } + + const serverDist = await getOutputPath(host, options.project, 'server'); + const pkg = JSON.parse(buffer.toString()) as { scripts?: Record }; + pkg.scripts = { + ...pkg.scripts, + 'dev:ssr': `ng run ${options.project}:${SERVE_SSR_TARGET_NAME}`, + 'serve:ssr': `node ${serverDist}/main.js`, + 'build:ssr': `ng build && ng run ${options.project}:server`, + 'prerender': `ng run ${options.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 tsConfig = new JSONFile(host, tsConfigPath); + const filesAstNode = tsConfig.get(['files']); + const serverFilePath = 'server.ts'; + if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) { + tsConfig.modify(['files'], [...filesAstNode, serverFilePath]); + } + }; +} + +function updateApplicationBuilderWorkspaceConfigRule( + projectRoot: string, + options: SSROptions, +): Rule { + return updateWorkspace((workspace) => { + const buildTarget = workspace.projects.get(options.project)?.targets.get('build'); + if (!buildTarget) { + return; + } + + buildTarget.options = { + ...buildTarget.options, + prerender: true, + ssr: join(normalize(projectRoot), 'server.ts'), + }; + }); +} + +function updateWebpackBuilderWorkspaceConfigRule(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 = join(normalize(project.root), '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 = 'server.ts'; + if (Array.isArray(filesAstNode) && !filesAstNode.some(({ text }) => text === serverFilePath)) { + tsConfig.modify(['files'], [...filesAstNode, serverFilePath]); + } + }; +} + +function addDependencies(): Rule { + return chain([ + addDependency('@angular/ssr', '^0.0.0-PLACEHOLDER', { + type: DependencyType.Default, + }), + addDependency('express', latestVersions['express'], { + type: DependencyType.Default, + }), + addDependency('@types/express', latestVersions['@types/express'], { + type: DependencyType.Dev, + }), + ]); +} + +function addServerFile(options: ServerOptions, isStandalone: boolean): Rule { + return async (host) => { + const workspace = await readWorkspace(host); + const project = workspace.projects.get(options.project); + if (!project) { + throw new SchematicsException(`Invalid project name (${options.project})`); + } + + const browserDistDirectory = await getOutputPath(host, options.project, 'build'); + + return mergeWith( + apply( + url( + `./files/${ + project?.targets?.get('build')?.builder === Builders.Application + ? 'application-builder' + : 'server-builder' + }`, + ), + [ + applyTemplates({ + ...strings, + ...options, + browserDistDirectory, + isStandalone, + }), + move(project.root), + ], + ), + ); + }; +} + +export default function (options: SSROptions): Rule { + return async (host) => { + const browserEntryPoint = await getMainFilePath(host, options.project); + const isStandalone = isStandaloneApp(host, browserEntryPoint); + + const workspace = await getWorkspace(host); + const clientProject = workspace.projects.get(options.project); + if (!clientProject) { + throw targetBuildNotFoundError(); + } + const isUsingApplicationBuilder = + clientProject.targets.get('build')?.builder === Builders.Application; + + return chain([ + externalSchematic('@schematics/angular', 'server', { + ...options, + skipInstall: true, + }), + ...(isUsingApplicationBuilder + ? [ + updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options), + updateApplicationBuilderTsConfigRule(options), + ] + : [ + addScriptsRule(options), + updateWebpackBuilderServerTsConfigRule(options), + updateWebpackBuilderWorkspaceConfigRule(options), + ]), + addServerFile(options, isStandalone), + addDependencies(), + ]); + }; +} diff --git a/packages/schematics/angular/ssr/index_spec.ts b/packages/schematics/angular/ssr/index_spec.ts new file mode 100644 index 000000000000..8aabccd7e573 --- /dev/null +++ b/packages/schematics/angular/ssr/index_spec.ts @@ -0,0 +1,206 @@ +/** + * @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 { tags } from '@angular-devkit/core'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +import { join } from 'node:path'; +import { Schema as ServerOptions } from './schema'; + +describe('SSR Schematic', () => { + const defaultOptions: ServerOptions = { + project: 'test-app', + }; + + const schematicRunner = new SchematicTestRunner( + '@schematics/angular', + require.resolve(join(__dirname, '../collection.json')), + ); + + let appTree: UnitTestTree; + + const workspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + version: '6.0.0', + }; + + beforeEach(async () => { + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'workspace', + workspaceOptions, + ); + }); + + describe('non standalone application', () => { + beforeEach(async () => { + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + { + name: 'test-app', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: 'css', + skipTests: false, + standalone: false, + }, + appTree, + ); + }); + + it('should add dependency: express', async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const filePath = '/package.json'; + const contents = tree.readContent(filePath); + expect(contents).toContain('express'); + }); + + it('should install npm dependencies', async () => { + await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + expect(schematicRunner.tasks.length).toBe(1); + expect(schematicRunner.tasks[0].name).toBe('node-package'); + expect((schematicRunner.tasks[0].options as { command: string }).command).toBe('install'); + }); + + it(`should update 'tsconfig.app.json' files with Express main file`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + const { files } = tree.readJson('/projects/test-app/tsconfig.app.json') as { + files: string[]; + }; + + expect(files).toEqual(['src/main.ts', 'src/main.server.ts', 'server.ts']); + }); + + it(`should import 'AppServerModule' from 'main.server.ts'`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const filePath = '/projects/test-app/server.ts'; + const content = tree.readContent(filePath); + expect(content).toContain(`import AppServerModule from './src/main.server';`); + }); + + it(`should pass 'AppServerModule' in the bootstrap parameter.`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const filePath = '/projects/test-app/server.ts'; + const content = tree.readContent(filePath); + expect(tags.oneLine`${content}`).toContain(tags.oneLine` + .render({ + bootstrap: AppServerModule, + `); + }); + }); + + describe('standalone application', () => { + beforeEach(async () => { + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + { + name: 'test-app', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: 'css', + skipTests: false, + standalone: true, + }, + appTree, + ); + }); + + it(`should add default import to 'main.server.ts'`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const filePath = '/projects/test-app/server.ts'; + const content = tree.readContent(filePath); + expect(content).toContain(`import bootstrap from './src/main.server';`); + }); + + it(`should pass 'AppServerModule' in the bootstrap parameter.`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const filePath = '/projects/test-app/server.ts'; + const content = tree.readContent(filePath); + expect(tags.oneLine`${content}`).toContain(tags.oneLine` + .render({ + bootstrap, + `); + }); + }); + + describe('Legacy browser builder', () => { + function convertBuilderToLegacyBrowser(): void { + const config = JSON.parse(appTree.readContent('/angular.json')); + const build = config.projects['test-app'].architect.build; + + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + + appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2)); + } + + beforeEach(async () => { + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + { + name: 'test-app', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: 'css', + skipTests: false, + standalone: false, + }, + appTree, + ); + + convertBuilderToLegacyBrowser(); + }); + + it(`should update 'tsconfig.server.json' files with Express main file`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const { files } = tree.readJson('/projects/test-app/tsconfig.server.json') as { + files: string[]; + }; + + expect(files).toEqual(['src/main.server.ts', 'server.ts']); + }); + + it(`should add export to main file in 'server.ts'`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const content = tree.readContent('/projects/test-app/server.ts'); + expect(content).toContain(`export default AppServerModule`); + }); + + it(`should add correct value to 'distFolder'`, async () => { + const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree); + + const content = tree.readContent('/projects/test-app/server.ts'); + expect(content).toContain(`const distFolder = join(process.cwd(), 'dist/test-app/browser');`); + }); + }); +}); diff --git a/packages/schematics/angular/ssr/schema.json b/packages/schematics/angular/ssr/schema.json new file mode 100644 index 000000000000..854acaa6234b --- /dev/null +++ b/packages/schematics/angular/ssr/schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsAngularSSR", + "title": "Angular SSR Options Schema", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + }, + "skipInstall": { + "description": "Skip installing dependency packages.", + "type": "boolean", + "default": false + } + }, + "required": ["project"], + "additionalProperties": false +} diff --git a/packages/schematics/angular/utility/latest-versions/package.json b/packages/schematics/angular/utility/latest-versions/package.json index d36433e3ffc6..6f6fb87fb00d 100644 --- a/packages/schematics/angular/utility/latest-versions/package.json +++ b/packages/schematics/angular/utility/latest-versions/package.json @@ -3,6 +3,7 @@ "comment": "This file is needed so that dependencies are synced by Renovate.", "private": true, "dependencies": { + "@types/express": "^4.17.17", "@types/jasmine": "~4.3.0", "@types/node": "^16.11.7", "express": "^4.18.2", diff --git a/renovate.json b/renovate.json index dbdb2712d1df..e41c97c194f0 100644 --- a/renovate.json +++ b/renovate.json @@ -55,8 +55,7 @@ "matchPaths": [ "packages/angular_devkit/schematics_cli/blank/project-files/package.json", "packages/angular_devkit/schematics_cli/schematic/files/package.json", - "packages/schematics/angular/utility/latest-versions/package.json", - "packages/angular/ssr/utility/latest-versions/package.json" + "packages/schematics/angular/utility/latest-versions/package.json" ], "matchPackagePatterns": ["*"], "groupName": "schematics dependencies", @@ -67,8 +66,7 @@ "matchPaths": [ "!packages/angular_devkit/schematics_cli/blank/project-files/package.json", "!packages/angular_devkit/schematics_cli/schematic/files/package.json", - "!packages/schematics/angular/utility/latest-versions/package.json", - "!packages/angular/ssr/utility/latest-versions/package.json" + "!packages/schematics/angular/utility/latest-versions/package.json" ], "excludePackagePatterns": ["^@angular/.*", "angular/dev-infra"], "matchPackagePatterns": ["*"],