-
Notifications
You must be signed in to change notification settings - Fork 393
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Migrate existing apps with application builder to use `index.se…
…rver.html` in server file (#19848)
- Loading branch information
Showing
6 changed files
with
511 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
/* | ||
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com> | ||
* | ||
* 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(); | ||
}; | ||
} |
147 changes: 147 additions & 0 deletions
147
projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
); | ||
}); | ||
}); |
149 changes: 149 additions & 0 deletions
149
projects/schematics/src/migrations/2211_35/ssr/update-ssr/update-server-files.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
/* | ||
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com> | ||
* | ||
* 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; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.