From 0ab6454ff33f8f4d123e24be664ee3ad89ccc87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Fra=C5=9B?= Date: Tue, 28 Jan 2025 13:44:55 +0100 Subject: [PATCH] feat: Migrate existing apps with application builder to use `index.server.html` in server file (#19848) --- .../src/migrations/2211_35/ssr/ssr.ts | 17 ++ .../src/migrations/2211_35/ssr/ssr_spec.ts | 147 +++++++++++++++++ .../ssr/update-ssr/update-server-files.ts | 149 ++++++++++++++++++ .../schematics/src/migrations/migrations.json | 5 + .../src/shared/utils/package-utils.ts | 118 +++++++++++--- .../src/shared/utils/package-utils_spec.ts | 93 +++++++++++ 6 files changed, 511 insertions(+), 18 deletions(-) create mode 100644 projects/schematics/src/migrations/2211_35/ssr/ssr.ts create mode 100644 projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts create mode 100644 projects/schematics/src/migrations/2211_35/ssr/update-ssr/update-server-files.ts diff --git a/projects/schematics/src/migrations/2211_35/ssr/ssr.ts b/projects/schematics/src/migrations/2211_35/ssr/ssr.ts new file mode 100644 index 00000000000..d0c4b3a943e --- /dev/null +++ b/projects/schematics/src/migrations/2211_35/ssr/ssr.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { checkIfSSRIsUsedWithApplicationBuilder } from '../../../shared/utils/package-utils'; +import { updateServerFile } from './update-ssr/update-server-files'; + +export function migrate(): Rule { + return (tree: Tree, _context: SchematicContext) => { + return checkIfSSRIsUsedWithApplicationBuilder(tree) + ? updateServerFile() + : noop(); + }; +} diff --git a/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts b/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts new file mode 100644 index 00000000000..fbe85601c50 --- /dev/null +++ b/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts @@ -0,0 +1,147 @@ +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { join } from 'path'; +import * as shared from '../../../shared/utils/package-utils'; + +jest.mock('../../../shared/utils/package-utils', () => ({ + ...jest.requireActual('../../../shared/utils/package-utils'), + checkIfSSRIsUsedWithApplicationBuilder: jest.fn(), +})); + +const collectionPath = join(__dirname, '../../migrations.json'); +const MIGRATION_SCRIPT_NAME = '01-migration-v2211_35-ssr'; + +describe('Update SSR Migration', () => { + let tree: Tree; + let runner: SchematicTestRunner; + + const workspaceContent = { + version: 1, + projects: { + app: { + root: '', + architect: { + build: { + builder: '@angular-devkit/build-angular:application', + }, + }, + }, + }, + }; + + const serverFileContent = ` + import { APP_BASE_HREF } from '@angular/common'; + import { + NgExpressEngineDecorator, + defaultExpressErrorHandlers, + ngExpressEngine as engine, + } from '@spartacus/setup/ssr'; + import express from 'express'; + import { readFileSync } from 'node:fs'; + import { dirname, join, resolve } from 'node:path'; + import { fileURLToPath } from 'node:url'; + import AppServerModule from './main.server'; + + const ngExpressEngine = NgExpressEngineDecorator.get(engine, { + ssrFeatureToggles: { + avoidCachingErrors: true, + }, + }); + + // The Express app is exported so that it can be used by serverless Functions. + export function app(): express.Express { + const server = express(); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const indexHtml = join(browserDistFolder, 'index.html'); + const indexHtmlContent = readFileSync(indexHtml, 'utf-8'); + } + `; + + beforeEach(() => { + tree = Tree.empty(); + runner = new SchematicTestRunner('migrations', collectionPath); + jest.resetAllMocks(); + tree.create('/angular.json', JSON.stringify(workspaceContent)); + }); + + it.each(['/server.ts', '/src/server.ts'])( + 'should update %s when using application builder and SSR is used', + async (filePath) => { + ( + shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock + ).mockReturnValue(true); + tree.create(filePath, serverFileContent); + + const newTree = await runner.runSchematic( + MIGRATION_SCRIPT_NAME, + {}, + tree + ); + const content = newTree.readText(filePath); + + expect(content).toContain('export function app()'); + expect(content).toContain("join(serverDistFolder, 'index.server.html')"); + expect(content).not.toContain("join(browserDistFolder, 'index.html')"); + expect( + shared.checkIfSSRIsUsedWithApplicationBuilder + ).toHaveBeenCalledWith(tree); + } + ); + + it('should not update when SSR is not used', async () => { + ( + shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock + ).mockReturnValue(false); + tree.create('/server.ts', serverFileContent); + + const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree); + const content = newTree.readText('/server.ts'); + + expect(content).toContain("join(browserDistFolder, 'index.html')"); + expect(content).not.toContain( + "join(serverDistFolder, 'index.server.html')" + ); + expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith( + tree + ); + }); + + it('should handle missing server.ts file', async () => { + ( + shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock + ).mockReturnValue(true); + + const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree); + + expect(newTree.exists('/server.ts')).toBe(false); + expect(newTree.exists('/src/server.ts')).toBe(false); + expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith( + tree + ); + }); + + it('should preserve other join statements when SSR is used', async () => { + ( + shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock + ).mockReturnValue(true); + + const contentWithMultipleJoins = ` + const otherFile = join(process.cwd(), 'other.html'); + const indexHtml = join(browserDistFolder, "index.html"); + const anotherFile = join(process.cwd(), 'another.html'); + `; + + tree.create('/server.ts', contentWithMultipleJoins); + + const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree); + const content = newTree.readText('/server.ts'); + + expect(content).toContain("join(process.cwd(), 'other.html')"); + expect(content).toContain('join(serverDistFolder, "index.server.html")'); + expect(content).toContain("join(process.cwd(), 'another.html')"); + expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith( + tree + ); + }); +}); diff --git a/projects/schematics/src/migrations/2211_35/ssr/update-ssr/update-server-files.ts b/projects/schematics/src/migrations/2211_35/ssr/update-ssr/update-server-files.ts new file mode 100644 index 00000000000..66c9b4d8627 --- /dev/null +++ b/projects/schematics/src/migrations/2211_35/ssr/update-ssr/update-server-files.ts @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { normalize } from '@angular-devkit/core'; +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import * as ts from 'typescript'; + +/** + * Finds the server.ts file in the project. + * Checks both root and src directory locations. + */ +function findServerFile(tree: Tree): string | null { + const possiblePaths = ['./server.ts', './src/server.ts']; + + for (const path of possiblePaths) { + if (tree.exists(normalize(path))) { + return path; + } + } + return null; +} + +/** + * Determines if a string literal uses single or double quotes. + * @param node - The string literal node to check + * @returns true for single quotes, false for double quotes + */ +function getQuotePreference(node: ts.StringLiteral): boolean { + return node.getText().startsWith("'"); +} + +interface JoinCallResult { + isMatch: boolean; + quotePreference?: boolean; +} + +/** + * Checks if a node is a join() call with 'index.html' as the last argument. + * Also determines the quote style used in the original code. + */ +function isJoinCallWithIndexHtml(node: ts.Node): JoinCallResult { + if (!ts.isCallExpression(node)) { + return { isMatch: false }; + } + + if (!ts.isIdentifier(node.expression) || node.expression.text !== 'join') { + return { isMatch: false }; + } + + const lastArg = node.arguments[node.arguments.length - 1]; + if (!ts.isStringLiteral(lastArg)) { + return { isMatch: false }; + } + + if (lastArg.text === 'index.html') { + return { + isMatch: true, + quotePreference: getQuotePreference(lastArg), + }; + } + + return { isMatch: false }; +} + +/** + * Visits the node and tries to find recursively the indexHtml const. + * When found, it updates the indexHtml const to use serverDistFolder and index.server.html. + */ +function visitNodeToUpdateIndexHtmlConst( + node: ts.Node, + content: string +): string { + let updatedContent = content; + + if (ts.isVariableStatement(node)) { + const declarations = node.declarationList.declarations; + if (declarations.length === 1) { + const declaration = declarations[0]; + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === 'indexHtml' && + declaration.initializer + ) { + const { isMatch, quotePreference } = isJoinCallWithIndexHtml( + declaration.initializer + ); + if (isMatch) { + const originalText = node.getText(); + const quote = quotePreference ? "'" : '"'; + const newText = `const indexHtml = join(serverDistFolder, ${quote}index.server.html${quote});`; + updatedContent = updatedContent.replace(originalText, newText); + } + } + } + } + + // Recursively visit all children + ts.forEachChild(node, (childNode) => { + updatedContent = visitNodeToUpdateIndexHtmlConst(childNode, updatedContent); + }); + + return updatedContent; +} + +/** + * Creates a rule that updates the server.ts file to use index.server.html + * instead of index.html when using the application builder. + */ +export function updateServerFile(): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.info('🔍 Checking if server.ts needs to be updated...'); + const serverFilePath = findServerFile(tree); + if (!serverFilePath) { + context.logger.warn('🔍 Could not find server.ts file - skipping update'); + return tree; + } + + context.logger.info( + `🔄 Updating ${serverFilePath} to use index.server.html` + ); + + const fileContentBuffer = tree.read(serverFilePath); + if (!fileContentBuffer) { + context.logger.warn( + `⚠️ Could not read ${serverFilePath} - skipping update` + ); + return tree; + } + + const content = fileContentBuffer.toString('utf-8'); + const sourceFile = ts.createSourceFile( + 'server.ts', + content, + ts.ScriptTarget.Latest, + true + ); + + const updatedContent = visitNodeToUpdateIndexHtmlConst(sourceFile, content); + + if (updatedContent !== content) { + tree.overwrite(serverFilePath, updatedContent); + context.logger.info(`✅ Successfully updated ${serverFilePath}`); + } + return tree; + }; +} diff --git a/projects/schematics/src/migrations/migrations.json b/projects/schematics/src/migrations/migrations.json index f153338039c..447e9d82997 100644 --- a/projects/schematics/src/migrations/migrations.json +++ b/projects/schematics/src/migrations/migrations.json @@ -238,6 +238,11 @@ "version": "2211.19.0", "factory": "./2211_19/angular-json-styles/angular-json-styles#migrate", "description": "Update the angular.json with the style preprocessor options" + }, + "01-migration-v2211_35-ssr": { + "version": "2211.35.0", + "factory": "./2211_35/ssr/ssr#migrate", + "description": "Updates server.ts file to use dist/server/index.server.ts" } } } diff --git a/projects/schematics/src/shared/utils/package-utils.ts b/projects/schematics/src/shared/utils/package-utils.ts index f83cf43ed59..dd04b6909c1 100644 --- a/projects/schematics/src/shared/utils/package-utils.ts +++ b/projects/schematics/src/shared/utils/package-utils.ts @@ -15,6 +15,7 @@ import { NodeDependency, NodeDependencyType, } from '@schematics/angular/utility/dependencies'; +import { normalize } from 'path'; import semver from 'semver'; import { version } from '../../../package.json'; import collectedDependencies from '../../dependencies.json'; @@ -30,7 +31,10 @@ import { } from '../libs-constants'; import { getServerTsPath } from './file-utils'; import { addPackageJsonDependencies, dependencyExists } from './lib-utils'; -import { getDefaultProjectNameFromWorkspace } from './workspace-utils'; +import { + getDefaultProjectNameFromWorkspace, + getWorkspace, +} from './workspace-utils'; const DEV_DEPENDENCIES_KEYWORDS = [ 'schematics', @@ -120,16 +124,6 @@ export function mapPackageToNodeDependencies( }; } -export function readPackageJson(tree: Tree): any { - const pkgPath = '/package.json'; - const buffer = tree.read(pkgPath); - if (!buffer) { - throw new SchematicsException('Could not find package.json'); - } - - return JSON.parse(buffer.toString(UTF_8)); -} - export function cleanSemverVersion(versionString: string): string { if (isNaN(Number(versionString.charAt(0)))) { return versionString.substring(1, versionString.length); @@ -156,14 +150,9 @@ export function getSpartacusCurrentFeatureLevel(): string { export function checkIfSSRIsUsed(tree: Tree): boolean { const projectName = getDefaultProjectNameFromWorkspace(tree); - const buffer = tree.read('angular.json'); - if (!buffer) { - throw new SchematicsException('Could not find angular.json'); - } - const angularFileBuffer = buffer.toString(UTF_8); - const angularJson = JSON.parse(angularFileBuffer); + const { workspace: angularJson } = getWorkspace(tree); const isServerConfiguration = - !!angularJson.projects[projectName].architect['server']; + !!angularJson?.projects[projectName]?.architect?.['server']; const serverFileLocation = getServerTsPath(tree); @@ -178,6 +167,82 @@ export function checkIfSSRIsUsed(tree: Tree): boolean { return !!(isServerConfiguration && isServerSideAvailable); } +interface ApplicationBuilderWorkspaceArchitect { + build?: { + builder: string; + options?: { + server?: string; + prerender?: boolean; + ssr?: { + entry?: string; + }; + }; + }; +} + +/** + * Checks if Server-Side Rendering (SSR) is configured and used with the new Angular application builder. + * + * @param tree - The file tree to check for SSR configuration + * @returns true if SSR is configured and the server file exists, false otherwise + */ +export function checkIfSSRIsUsedWithApplicationBuilder(tree: Tree): boolean { + const projectName = getDefaultProjectNameFromWorkspace(tree); + const { workspace: angularJson } = getWorkspace(tree); + const architect = angularJson.projects[projectName] + .architect as ApplicationBuilderWorkspaceArchitect; + const builderType = architect?.build?.builder; + const buildOptions = architect?.build?.options; + + if ( + typeof builderType !== 'string' || + builderType !== '@angular-devkit/build-angular:application' + ) { + return false; + } + + // Check if SSR is configured in build options + const hasSSRConfig = buildOptions?.server && buildOptions?.ssr?.entry; + if (!hasSSRConfig) { + return false; + } + + const serverFileLocation = getServerTsPathForApplicationBuilder(tree); + if (!serverFileLocation) { + return false; + } + + const serverBuffer = tree.read(serverFileLocation); + const serverFileBuffer = serverBuffer?.toString(UTF_8); + return Boolean(serverFileBuffer?.length); +} + +/** + * Gets the path to the server.ts file for applications using the Angular application builder. + * Looks up the configured server entry point in angular.json and verifies it exists. + * + * @param tree - The file tree to check for the server file + * @returns The normalized path to the server.ts file if found, null otherwise + */ +export function getServerTsPathForApplicationBuilder( + tree: Tree +): string | null { + const projectName = getDefaultProjectNameFromWorkspace(tree); + const { workspace: angularJson } = getWorkspace(tree); + const architect = angularJson.projects[projectName] + .architect as ApplicationBuilderWorkspaceArchitect; + const buildOptions = architect?.build?.options; + + // Get server file path from SSR configuration + if (buildOptions?.ssr?.entry) { + const configuredPath = normalize(buildOptions.ssr.entry); + if (tree.exists(configuredPath)) { + return configuredPath; + } + } + return null; +} + export function prepareSpartacusDependencies(): NodeDependency[] { const spartacusVersion = getPrefixedSpartacusSchematicsVersion(); @@ -270,3 +335,20 @@ function getCurrentDependencyVersion( const currentVersion = dependencies[dependency.name]; return semver.parse(cleanSemverVersion(currentVersion)); } + +/** + * Reads and parses the package.json file from the root of the project. + * + * @param tree - The virtual file tree provided by the Angular schematics + * @returns The parsed package.json content as an object + * @throws SchematicsException if package.json cannot be found or parsed + */ +export function readPackageJson(tree: Tree): any { + const pkgPath = '/package.json'; + const buffer = tree.read(pkgPath); + if (!buffer) { + throw new SchematicsException('Could not find package.json'); + } + + return JSON.parse(buffer.toString(UTF_8)); +} diff --git a/projects/schematics/src/shared/utils/package-utils_spec.ts b/projects/schematics/src/shared/utils/package-utils_spec.ts index 7394145c43f..5a8eb3d20ed 100644 --- a/projects/schematics/src/shared/utils/package-utils_spec.ts +++ b/projects/schematics/src/shared/utils/package-utils_spec.ts @@ -1,3 +1,4 @@ +import { Tree } from '@angular-devkit/schematics'; import { SchematicTestRunner, UnitTestTree, @@ -11,7 +12,9 @@ import * as path from 'path'; import { UTF_8 } from '../constants'; import { SPARTACUS_SCHEMATICS } from '../libs-constants'; import { + checkIfSSRIsUsedWithApplicationBuilder, getMajorVersionNumber, + getServerTsPathForApplicationBuilder, getSpartacusCurrentFeatureLevel, getSpartacusSchematicsVersion, readPackageJson, @@ -104,4 +107,94 @@ describe('Package utils', () => { expect(featureLevel).toEqual(version.substring(0, 7)); }); }); + + describe('SSR Application Builder Tests', () => { + let ssrTree: UnitTestTree; + + const createWorkspace = ( + builderType: string, + serverPath?: string, + ssrEntry?: string + ) => { + const angularJson = { + projects: { + test: { + architect: { + build: { + builder: builderType, + options: { + ...(serverPath && { server: serverPath }), + ...(ssrEntry && { ssr: { entry: ssrEntry } }), + }, + }, + }, + }, + }, + }; + ssrTree.create('angular.json', JSON.stringify(angularJson)); + }; + + beforeEach(() => { + ssrTree = new UnitTestTree(Tree.empty()); + }); + + describe('checkIfSSRIsUsedWithApplicationBuilder', () => { + it('should return false when builder is not application builder', () => { + createWorkspace('@angular-devkit/build-angular:browser'); + expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeFalsy(); + }); + + it('should return false when SSR config is missing', () => { + createWorkspace('@angular-devkit/build-angular:application'); + expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeFalsy(); + }); + + it('should return false when server file does not exist', () => { + createWorkspace( + '@angular-devkit/build-angular:application', + 'src/main.server.ts', + 'server.ts' + ); + expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeFalsy(); + }); + + it('should return true when SSR is properly configured and server file exists', () => { + createWorkspace( + '@angular-devkit/build-angular:application', + 'src/main.server.ts', + 'server.ts' + ); + ssrTree.create('server.ts', 'export const server = {};'); + expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeTruthy(); + }); + }); + + describe('getServerTsPathForApplicationBuilder', () => { + it('should return configured SSR entry path when it exists', () => { + createWorkspace( + '@angular-devkit/build-angular:application', + 'src/main.server.ts', + 'custom/server.ts' + ); + ssrTree.create('custom/server.ts', 'export const server = {};'); + expect(getServerTsPathForApplicationBuilder(ssrTree)).toBe( + 'custom/server.ts' + ); + }); + + it('should return null when configured SSR entry path does not exist', () => { + createWorkspace( + '@angular-devkit/build-angular:application', + 'src/main.server.ts', + 'custom/server.ts' + ); + expect(getServerTsPathForApplicationBuilder(ssrTree)).toBeNull(); + }); + + it('should return null when no server file exists in any location', () => { + createWorkspace('@angular-devkit/build-angular:application'); + expect(getServerTsPathForApplicationBuilder(ssrTree)).toBeNull(); + }); + }); + }); });