From cc245e9e99bd907f27b8777681f4347c0d026311 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Wed, 22 Oct 2025 11:13:10 +0300 Subject: [PATCH 1/7] ssr migration command added --- .../application-builder/server.ts.template | 192 ++++++++ .../files/server-builder/server.ts.template | 74 +++ .../schematics/src/commands/ssr-add/index.ts | 444 ++++++++++++++++++ .../src/commands/ssr-add/schema.json | 23 + .../app/app.module.server.ts.template | 23 + .../app/app.routes.server.ts.template | 8 + .../ngmodule-src/main.server.ts.template | 1 + .../app/app.config.server.ts.template | 29 ++ .../app/app.routes.server.ts.template | 8 + .../standalone-src/main.server.ts.template | 7 + .../app/app.module.server.ts.template | 28 ++ .../ngmodule-src/main.server.ts.template | 1 + .../root/tsconfig.server.json.template | 15 + .../app/app.config.server.ts.template | 29 ++ .../standalone-src/main.server.ts.template | 7 + .../src/commands/ssr-add/server/index.ts | 268 +++++++++++ .../src/commands/ssr-add/server/schema.json | 23 + 17 files changed, 1180 insertions(+) create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts create mode 100644 npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template new file mode 100644 index 00000000000..dcb25952a3d --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/application-builder/server.ts.template @@ -0,0 +1,192 @@ +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; +import express from 'express'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import {environment} from './environments/environment'; +import { ServerCookieParser } from '@abp/ng.core'; + +// ESM import +import * as oidc from 'openid-client'; + +if (environment.production === false) { + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; +} + +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const browserDistFolder = resolve(serverDistFolder, '../browser'); + +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +const ISSUER = new URL(environment.oAuthConfig.issuer); +const CLIENT_ID = environment.oAuthConfig.clientId; +const REDIRECT_URI = environment.oAuthConfig.redirectUri; +const SCOPE = environment.oAuthConfig.scope; +// @ts-ignore +const CLIENT_SECRET = environment.oAuthConfig.clientSecret || undefined; + +const config = await oidc.discovery(ISSUER, CLIENT_ID, CLIENT_SECRET); +const secureCookie = { httpOnly: true, sameSite: 'lax' as const, secure: environment.production, path: '/' }; +const tokenCookie = { ...secureCookie, httpOnly: false }; + +app.use(ServerCookieParser.middleware()); + +const sessions = new Map(); + +app.get('/authorize', async (_req, res) => { + const code_verifier = oidc.randomPKCECodeVerifier(); + const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier); + const state = oidc.randomState(); + + if (_req.query.returnUrl) { + const returnUrl = String(_req.query.returnUrl || null); + res.cookie('returnUrl', returnUrl, { ...secureCookie, maxAge: 5 * 60 * 1000 }); + } + + const sid = crypto.randomUUID(); + sessions.set(sid, { pkce: code_verifier, state }); + res.cookie('sid', sid, secureCookie); + + const url = oidc.buildAuthorizationUrl(config, { + redirect_uri: REDIRECT_URI, + scope: SCOPE, + code_challenge, + code_challenge_method: 'S256', + state, + }); + res.redirect(url.toString()); +}); + +app.get('/logout', async (req, res) => { + try { + const sid = req.cookies.sid; + + if (sid && sessions.has(sid)) { + sessions.delete(sid); + } + + res.clearCookie('sid', secureCookie); + res.clearCookie('access_token', tokenCookie); + res.clearCookie('refresh_token', secureCookie); + res.clearCookie('expires_at', tokenCookie); + res.clearCookie('returnUrl', secureCookie); + + const endSessionEndpoint = config.serverMetadata().end_session_endpoint; + if (endSessionEndpoint) { + const logoutUrl = new URL(endSessionEndpoint); + logoutUrl.searchParams.set('post_logout_redirect_uri', REDIRECT_URI); + logoutUrl.searchParams.set('client_id', CLIENT_ID); + + return res.redirect(logoutUrl.toString()); + } + res.redirect('/'); + + } catch (error) { + console.error('Logout error:', error); + res.status(500).send('Logout error'); + } +}); + +app.get('/', async (req, res, next) => { + try { + const { code, state } = req.query as any; + if (!code || !state) return next(); + + const sid = req.cookies.sid; + const sess = sid && sessions.get(sid); + if (!sess || state !== sess.state) return res.status(400).send('invalid state'); + + const tokenEndpoint = config.serverMetadata().token_endpoint!; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: String(code), + redirect_uri: environment.oAuthConfig.redirectUri, + code_verifier: sess.pkce!, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET || '' + }); + + const resp = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!resp.ok) { + const errTxt = await resp.text(); + console.error('token error:', resp.status, errTxt); + return res.status(500).send('token error'); + } + + const tokens = await resp.json(); + + const expiresInSec = + Number(tokens.expires_in ?? tokens.expiresIn ?? 3600); + const skewSec = 60; + const accessExpiresAt = new Date( + Date.now() + Math.max(0, expiresInSec - skewSec) * 1000 + ); + + sessions.set(sid, { ...sess, at: tokens.access_token, refresh: tokens.refresh_token }); + res.cookie('access_token', tokens.access_token, {...tokenCookie, maxAge: accessExpiresAt.getTime()}); + res.cookie('refresh_token', tokens.refresh_token, secureCookie); + res.cookie('expires_at', String(accessExpiresAt.getTime()), tokenCookie); + + const returnUrl = req.cookies?.returnUrl ?? '/'; + res.clearCookie('returnUrl', secureCookie); + + return res.redirect(returnUrl); + } catch (e) { + console.error('OIDC error:', e); + return res.status(500).send('oidc error'); + } +}); + +/** + * Serve static files from /browser + */ +app.use( + express.static(browserDistFolder, { + maxAge: '1y', + index: false, + redirect: false, + }), +); + +/** + * Handle all other requests by rendering the Angular application. + */ +app.use((req, res, next) => { + angularApp + .handle(req) + .then(response => { + if (response) { + res.cookie('ssr-init', 'true', {...secureCookie, httpOnly: false}); + return writeResponseToNodeResponse(response, res); + } else { + return next() + } + }) + .catch(next); +}); + +/** + * Start the server if this module is the main entry point. + * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. + */ +if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4200; + app.listen(port, () => { + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +/** + * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions. + */ +export const reqHandler = createNodeRequestHandler(app); diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template new file mode 100644 index 00000000000..e20cc2e222d --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template @@ -0,0 +1,74 @@ +import 'zone.js/node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr/node'; +import express from 'express'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> from './main.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const distFolder = join(process.cwd(), '<%= browserDistDirectory %>'); + const indexHtml = existsSync(join(distFolder, 'index.original.html')) + ? join(distFolder, 'index.original.html') + : join(distFolder, 'index.html'); + + const commonEngine = new CommonEngine(); + + server.set('view engine', 'html'); + server.set('views', distFolder); + + // Example Express Rest API endpoints + // server.get('/api/{*splat}', (req, res) => { }); + // Serve static files from /browser + server.use(express.static(distFolder, { + maxAge: '1y', + index: false, + })); + + // All regular routes use the Angular engine + server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; + + commonEngine + .render({ + <% if (isStandalone) { %>bootstrap<% } else { %>bootstrap: AppServerModule<% } %>, + documentFilePath: indexHtml, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: distFolder, + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }, { provide: 'cookies', useValue: JSON.stringify(req.headers.cookie) }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); + }); + + return server; +} + +function run(): void { + const port = process.env['PORT'] || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, (error) => { + if (error) { + throw error; + } + + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +// Webpack will replace 'require' with '__webpack_require__' +// '__non_webpack_require__' is a proxy to Node 'require' +// The below code is to ensure that the server is run only when not requiring the bundle. +declare const __non_webpack_require__: NodeRequire; +const mainModule = __non_webpack_require__.main; +const moduleFilename = mainModule && mainModule.filename || ''; +if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { + run(); +} + +export default <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %>; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts b/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts new file mode 100644 index 00000000000..17e4bb2912c --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts @@ -0,0 +1,444 @@ +/** + * @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'; +// @ts-ignore +import { Schema as ServerOptions } from './server/schema'; +import { + DependencyType, + ExistingBehavior, + InstallBehavior, + addDependency, + readWorkspace, + updateWorkspace, +} from '../../utils/angular'; +import { JSONFile } from '../../utils/angular/json-file'; +import { latestVersions } from '../../utils/angular/latest-versions'; +import { isStandaloneApp } from '../../utils/angular/ng-ast-utils'; +import { + isUsingApplicationBuilder, + targetBuildNotFoundError, +} from '../../utils/angular/project-targets'; +import { getMainFilePath } from '../../utils/angular/standalone/util'; +import { getWorkspace } from '../../utils/angular/workspace'; + +// @ts-ignore +import { Schema as SSROptions } from './schema'; + +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 { + // 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.`); + } + + let { outputPath } = architectTarget.options; + // Use default if not explicitly specified + outputPath ??= posix.join('dist', projectName); + + 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 } | 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 include = json.get(['include']); + if (Array.isArray(include) && include.includes('src/**/*.ts')) { + return; + } + + const filesPath = ['files']; + const files = new Set((json.get(filesPath) as string[] | undefined) ?? []); + files.add('src/server.ts'); + json.modify(filesPath, [...files]); + }; +} + +function updateRootTsConfigRule(options: SSROptions): Rule { + return async (host: Tree) => { + const workspace = await readWorkspace(host); + const project = workspace.projects.get(options.project); + + let tsConfigPath: string | undefined = options.tsconfigPath; + + if (!tsConfigPath && project) { + const projRoot = normalize(project.root || ''); + const candidate = projRoot ? join(projRoot, 'tsconfig.json') : 'tsconfig.json'; + if (host.exists(candidate)) { + tsConfigPath = candidate; + } + } + + if (!tsConfigPath && host.exists('tsconfig.json')) { + tsConfigPath = 'tsconfig.json'; + } + + if (!tsConfigPath || !host.exists(tsConfigPath)) { + return; + } + + const json = new JSONFile(host, tsConfigPath); + + const moduleResolutionPath = ['compilerOptions', 'moduleResolution']; + const modulePath = ['compilerOptions', 'module']; + + const currentModuleResolution = json.get(moduleResolutionPath); + if (currentModuleResolution !== 'bundler') { + json.modify(moduleResolutionPath, 'bundler'); + } + const currentModule = json.get(modulePath); + if (currentModule !== 'preserve') { + json.modify(modulePath, 'preserve'); + } + }; +} + +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: 'server', + 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.dependencies.express, { + type: DependencyType.Default, + install, + existing: ExistingBehavior.Replace, + }), + addDependency('@types/express', latestVersions.dependencies['@types/express'], { + type: DependencyType.Dev, + install, + existing: ExistingBehavior.Replace, + }), + addDependency('openid-client', latestVersions.dependencies['openid-client'], { + type: DependencyType.Default, + install, + existing: ExistingBehavior.Skip, + }), + ]; + + if (!isUsingApplicationBuilder) { + rules.push( + addDependency('browser-sync', latestVersions.dependencies['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 usingApplicationBuilder = isUsingApplicationBuilder(project); + const browserDistDirectory = usingApplicationBuilder + ? (await getApplicationBuilderOutputPaths(host, projectName)).browser + : await getLegacyOutputPaths(host, projectName, 'build'); + + return mergeWith( + apply(url(`./files/${usingApplicationBuilder ? 'application-builder' : 'server-builder'}`), [ + applyTemplates({ + ...strings, + ...options, + browserDistDirectory, + isStandalone, + }), + move(projectSourceRoot), + ]), + ); + }; +} + +export default function (options: SSROptions): Rule { + return async (host, context) => { + 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 usingApplicationBuilder = isUsingApplicationBuilder(clientProject); + const sourceRoot = clientProject.sourceRoot ?? posix.join(clientProject.root, 'src'); + + return chain([ + schematic('server', { + ...options, + skipInstall: true, + }), + ...(usingApplicationBuilder + ? [ + updateApplicationBuilderWorkspaceConfigRule(sourceRoot, options, context), + updateApplicationBuilderTsConfigRule(options), + updateRootTsConfigRule(options) + ] + : [ + updateWebpackBuilderServerTsConfigRule(options), + updateWebpackBuilderWorkspaceConfigRule(sourceRoot, options), + ]), + addServerFile(sourceRoot, options, isStandalone), + addScriptsRule(options, usingApplicationBuilder), + addDependencies(options, usingApplicationBuilder), + ]); + }; +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json b/npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json new file mode 100644 index 00000000000..08699b8ff2d --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "SchematicsAngularSSR", + "title": "ABP Angular SSR Options Schema", + "type": "object", + "description": "Enables Server-Side Rendering (SSR) for your Angular application. SSR allows your app to be rendered on the server, which can significantly improve its initial load performance and Search Engine Optimization (SEO). This schematic configures your project for SSR, generating the necessary files and making the required modifications to your project's structure.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project you want to enable SSR for.", + "$default": { + "$source": "projectName" + } + }, + "skipInstall": { + "description": "Skip the automatic installation of packages. You will need to manually install the dependencies later.", + "type": "boolean", + "default": false + } + }, + "required": ["project"], + "additionalProperties": false +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template new file mode 100644 index 00000000000..04a11291641 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.module.server.ts.template @@ -0,0 +1,23 @@ +import { NgModule, inject, PLATFORM_ID, TransferState } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { <%= appModuleName %> } from '<%= appModulePath %>'; +import { serverRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; + +@NgModule({ + imports: [<%= appModuleName %>], + providers: [{ + provide: APP_INITIALIZER, + useFactory: () => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }, + multi: true + }, provideServerRendering(withRoutes(serverRoutes))], + bootstrap: [<%= appComponentName %>], +}) +export class AppServerModule {} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template new file mode 100644 index 00000000000..2c5a11d34b8 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/app/app.routes.server.ts.template @@ -0,0 +1,8 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Server + } +]; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template new file mode 100644 index 00000000000..dfb6fdb3f1f --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/ngmodule-src/main.server.ts.template @@ -0,0 +1 @@ +export { AppServerModule as default } from './app/app.module.server'; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template new file mode 100644 index 00000000000..8ae2e03e000 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.config.server.ts.template @@ -0,0 +1,29 @@ +import { + mergeApplicationConfig, + ApplicationConfig, + provideAppInitializer, + inject, + PLATFORM_ID, + TransferState +} from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; + +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideAppInitializer(() => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }), + provideServerRendering(withRoutes(serverRoutes)), + ], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template new file mode 100644 index 00000000000..2c5a11d34b8 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/app/app.routes.server.ts.template @@ -0,0 +1,8 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Server + } +]; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template new file mode 100644 index 00000000000..bc0b6ba5975 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/application-builder/standalone-src/main.server.ts.template @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { config } from './app/app.config.server'; + +const bootstrap = () => bootstrapApplication(<%= appComponentName %>, config); + +export default bootstrap; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template new file mode 100644 index 00000000000..f5b86f4a6eb --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template @@ -0,0 +1,28 @@ +import { NgModule, inject, PLATFORM_ID, TransferState } from '@angular/core'; +import { ServerModule } from '@angular/platform-server'; + +import { <%= appModuleName %> } from '<%= appModulePath %>'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { SSR_FLAG } from '@abp/ng.core'; + +@NgModule({ + imports: [ + <%= appModuleName %>, + ServerModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + useFactory: () => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }, + multi: true + } + ], + bootstrap: [<%= appComponentName %>], +}) +export class AppServerModule {} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template new file mode 100644 index 00000000000..dfb6fdb3f1f --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/main.server.ts.template @@ -0,0 +1 @@ +export { AppServerModule as default } from './app/app.module.server'; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template new file mode 100644 index 00000000000..392d4570677 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/root/tsconfig.server.json.template @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./<%= tsConfigExtends %>", + "compilerOptions": { + "outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/server", + "types": [ + "node"<% if (hasLocalizePackage) { %>, + "@angular/localize"<% } %> + ] + }, + "files": [ + "src/main.server.ts" + ] +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template new file mode 100644 index 00000000000..a27a4affdee --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/app/app.config.server.ts.template @@ -0,0 +1,29 @@ +import { + mergeApplicationConfig, + ApplicationConfig, + provideAppInitializer, + inject, + PLATFORM_ID, + TransferState +} from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; + +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideAppInitializer(() => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }), + provideServerRendering(withRoutes(serverRoutes)) + ] +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template new file mode 100644 index 00000000000..bc0b6ba5975 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/standalone-src/main.server.ts.template @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { <%= appComponentName %> } from '<%= appComponentPath %>'; +import { config } from './app/app.config.server'; + +const bootstrap = () => bootstrapApplication(<%= appComponentName %>, config); + +export default bootstrap; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts new file mode 100644 index 00000000000..3cd2d0371c8 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/index.ts @@ -0,0 +1,268 @@ +/** + * @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 { JsonValue, Path, basename, dirname, join, normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicsException, + Tree, + apply, + applyTemplates, + chain, + mergeWith, + move, + strings, + url, +} from '@angular-devkit/schematics'; +import { posix } from 'node:path'; +import { + DependencyType, + InstallBehavior, + addDependency, + addRootProvider, +} from '../../../utils/angular'; +import { getPackageJsonDependency } from '../../../utils/angular'; +import { JSONFile } from '../../../utils/angular/json-file'; +import { latestVersions } from '../../../utils/angular/latest-versions'; +import { isStandaloneApp } from '../../../utils/angular/ng-ast-utils'; +import { relativePathToWorkspaceRoot } from '../../../utils/angular/paths'; +import { + isUsingApplicationBuilder, + targetBuildNotFoundError, +} from '../../../utils/angular/project-targets'; +import { resolveBootstrappedComponentData } from '../../../utils/angular/standalone/app_component'; +import { getMainFilePath } from '../../../utils/angular/standalone/util'; +import { getWorkspace, updateWorkspace } from '../../../utils/angular/workspace'; +import { Builders } from '../../../utils/angular/workspace-models'; + +// @ts-ignore +import { Schema as ServerOptions } from './schema'; + +const serverMainEntryName = 'main.server.ts'; + +function updateConfigFileBrowserBuilder(options: ServerOptions, tsConfigDirectory: Path): Rule { + return updateWorkspace(workspace => { + const clientProject = workspace.projects.get(options.project); + + if (clientProject) { + // In case the browser builder hashes the assets + // we need to add this setting to the server builder + // as otherwise when assets it will be requested twice. + // One for the server which will be unhashed, and other on the client which will be hashed. + const getServerOptions = (options: Record = {}): {} => { + return { + buildOptimizer: options?.buildOptimizer, + outputHashing: options?.outputHashing === 'all' ? 'media' : options?.outputHashing, + fileReplacements: options?.fileReplacements, + optimization: options?.optimization === undefined ? undefined : !!options?.optimization, + sourceMap: options?.sourceMap, + localization: options?.localization, + stylePreprocessorOptions: options?.stylePreprocessorOptions, + resourcesOutputPath: options?.resourcesOutputPath, + deployUrl: options?.deployUrl, + i18nMissingTranslation: options?.i18nMissingTranslation, + preserveSymlinks: options?.preserveSymlinks, + extractLicenses: options?.extractLicenses, + inlineStyleLanguage: options?.inlineStyleLanguage, + vendorChunk: options?.vendorChunk, + }; + }; + + const buildTarget = clientProject.targets.get('build'); + if (buildTarget?.options) { + buildTarget.options.outputPath = `dist/${options.project}/browser`; + } + + const buildConfigurations = buildTarget?.configurations; + const configurations: Record = {}; + if (buildConfigurations) { + for (const [key, options] of Object.entries(buildConfigurations)) { + configurations[key] = getServerOptions(options); + } + } + + const sourceRoot = clientProject.sourceRoot ?? join(normalize(clientProject.root), 'src'); + const serverTsConfig = join(tsConfigDirectory, 'tsconfig.server.json'); + clientProject.targets.add({ + name: 'server', + builder: Builders.Server, + defaultConfiguration: 'production', + options: { + outputPath: `dist/${options.project}/server`, + main: join(normalize(sourceRoot), serverMainEntryName), + tsConfig: serverTsConfig, + ...(buildTarget?.options ? getServerOptions(buildTarget?.options) : {}), + }, + configurations, + }); + } + }); +} + +function updateConfigFileApplicationBuilder(options: ServerOptions): Rule { + return updateWorkspace(workspace => { + const project = workspace.projects.get(options.project); + if (!project) { + return; + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + return; + } + + buildTarget.options ??= {}; + buildTarget.options['server'] = posix.join( + project.sourceRoot ?? posix.join(project.root, 'src'), + serverMainEntryName, + ); + + buildTarget.options['outputMode'] = 'static'; + }); +} + +function updateTsConfigFile(tsConfigPath: string): Rule { + return (host: Tree) => { + const json = new JSONFile(host, tsConfigPath); + // Skip adding the files entry if the server entry would already be included. + const include = json.get(['include']); + if (!Array.isArray(include) || !include.includes('src/**/*.ts')) { + const filesPath = ['files']; + const files = new Set((json.get(filesPath) as string[] | undefined) ?? []); + files.add('src/' + serverMainEntryName); + json.modify(filesPath, [...files]); + } + + const typePath = ['compilerOptions', 'types']; + const types = new Set((json.get(typePath) as string[] | undefined) ?? []); + types.add('node'); + json.modify(typePath, [...types]); + }; +} + +function addDependencies(skipInstall: boolean | undefined): Rule { + return (host: Tree) => { + const coreDep = getPackageJsonDependency(host, '@angular/core'); + if (coreDep === null) { + throw new SchematicsException('Could not find version.'); + } + + const install = skipInstall ? InstallBehavior.None : InstallBehavior.Auto; + + return chain([ + addDependency('@angular/ssr', '~20.0.0', { + type: DependencyType.Default, + install, + }), + addDependency('@angular/platform-server', coreDep.version, { + type: DependencyType.Default, + install, + }), + addDependency('@types/node', latestVersions.dependencies['@types/node'], { + type: DependencyType.Dev, + install, + }), + ]); + }; +} + +export default function (options: ServerOptions): Rule { + return async (host: Tree) => { + const workspace = await getWorkspace(host); + const clientProject = workspace.projects.get(options.project); + if (clientProject?.extensions.projectType !== 'application') { + throw new SchematicsException(`Server schematic requires a project type of "application".`); + } + + const clientBuildTarget = clientProject.targets.get('build'); + if (!clientBuildTarget) { + throw targetBuildNotFoundError(); + } + + const usingApplicationBuilder = isUsingApplicationBuilder(clientProject); + + if ( + clientProject.targets.has('server') || + (usingApplicationBuilder && clientBuildTarget.options?.server !== undefined) + ) { + // Server has already been added. + return; + } + + const clientBuildOptions = clientBuildTarget.options as Record; + const browserEntryPoint = await getMainFilePath(host, options.project); + const isStandalone = isStandaloneApp(host, browserEntryPoint); + const sourceRoot = clientProject.sourceRoot ?? join(normalize(clientProject.root), 'src'); + + let filesUrl = `./files/${usingApplicationBuilder ? 'application-builder/' : 'server-builder/'}`; + filesUrl += isStandalone ? 'standalone-src' : 'ngmodule-src'; + + const { componentName, componentImportPathInSameFile, moduleName, moduleImportPathInSameFile } = + resolveBootstrappedComponentData(host, browserEntryPoint) || { + componentName: 'App', + componentImportPathInSameFile: './app/app', + moduleName: 'AppModule', + moduleImportPathInSameFile: './app/app.module', + }; + const templateSource = apply(url(filesUrl), [ + applyTemplates({ + ...strings, + ...options, + appComponentName: componentName, + appComponentPath: componentImportPathInSameFile, + appModuleName: moduleName, + appModulePath: + moduleImportPathInSameFile === null + ? null + : `./${posix.basename(moduleImportPathInSameFile)}`, + }), + move(sourceRoot), + ]); + + const clientTsConfig = normalize(clientBuildOptions.tsConfig); + const tsConfigExtends = basename(clientTsConfig); + const tsConfigDirectory = dirname(clientTsConfig); + + return chain([ + mergeWith(templateSource), + ...(usingApplicationBuilder + ? [ + updateConfigFileApplicationBuilder(options), + updateTsConfigFile(clientBuildOptions.tsConfig), + ] + : [ + mergeWith( + apply(url('./files/server-builder/root'), [ + applyTemplates({ + ...strings, + ...options, + stripTsExtension: (s: string) => s.replace(/\.ts$/, ''), + tsConfigExtends, + hasLocalizePackage: !!getPackageJsonDependency(host, '@angular/localize'), + relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(tsConfigDirectory), + }), + move(tsConfigDirectory), + ]), + ), + updateConfigFileBrowserBuilder(options, tsConfigDirectory), + ]), + addDependencies(options.skipInstall), + addRootProvider( + options.project, + ({ code, external }) => + code`${external('provideClientHydration', '@angular/platform-browser')}(${external( + 'withEventReplay', + '@angular/platform-browser', + )}(), ${external( + 'withIncrementalHydration', + '@angular/platform-browser', + )}(), ${external('withHttpTransferCacheOptions', '@angular/platform-browser')}({}))`, + ), + ]); + }; +} diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json new file mode 100644 index 00000000000..225574d9215 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsAngularServerApp", + "title": "Angular Server App Options Schema", + "type": "object", + "additionalProperties": false, + "description": "Sets up server-side rendering (SSR) for your Angular application. SSR allows your app to be rendered on the server, improving initial load performance and SEO. This schematic configures your project for SSR and generates the necessary files.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project to enable server-side rendering for.", + "$default": { + "$source": "projectName" + } + }, + "skipInstall": { + "description": "Skip the automatic installation of packages. You will need to manually install the dependencies later.", + "type": "boolean", + "default": false + } + }, + "required": ["project"] +} From 31cdec8118ddbd3d10b998911be29c75305ff2b6 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Wed, 22 Oct 2025 11:24:33 +0300 Subject: [PATCH 2/7] refactoring --- .../schematics/src/utils/angular/index.ts | 1 + .../utils/angular/latest-versions/index.ts | 28 +++++++++++++++++++ .../schematics/src/utils/angular/workspace.ts | 2 ++ npm/ng-packs/scripts/build-schematics.ts | 3 ++ 4 files changed, 34 insertions(+) create mode 100644 npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/index.ts b/npm/ng-packs/packages/schematics/src/utils/angular/index.ts index 2c1bfd086e3..30d203c6430 100644 --- a/npm/ng-packs/packages/schematics/src/utils/angular/index.ts +++ b/npm/ng-packs/packages/schematics/src/utils/angular/index.ts @@ -12,3 +12,4 @@ export * from './validation'; export * from './workspace'; export * from './workspace-models'; export * from './standalone'; +export * from './dependency'; diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts new file mode 100644 index 00000000000..860f34a7aa2 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions/index.ts @@ -0,0 +1,28 @@ +export const latestVersions = { + description: 'Package versions used by schematics in @schematics/angular.', + comment: 'This file is needed so that dependencies are synced by Renovate.', + private: true, + dependencies: { + '@types/express': '~5.0.0', + '@types/jasmine': '~5.1.0', + '@types/node': '~20.11.0', + 'browser-sync': '^3.0.0', + express: '~5.1.0', + 'jasmine-core': '~5.9.0', + 'jasmine-spec-reporter': '~7.0.0', + 'karma-chrome-launcher': '~3.2.0', + 'karma-coverage': '~2.2.0', + 'karma-jasmine-html-reporter': '~2.1.0', + 'karma-jasmine': '~5.1.0', + karma: '~6.4.0', + less: '^4.2.0', + postcss: '^8.5.3', + protractor: '~7.0.0', + rxjs: '~7.8.0', + tslib: '^2.3.0', + 'ts-node': '~10.9.0', + typescript: '~5.8.0', + 'zone.js': '~0.15.0', + 'openid-client': '^6.6.4', + }, +}; diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts b/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts index b831458edf4..6d44e082418 100644 --- a/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts +++ b/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts @@ -161,3 +161,5 @@ export function* allTargetOptions( } } } + +export { getWorkspace as readWorkspace } from './workspace'; // for backwards compatibility diff --git a/npm/ng-packs/scripts/build-schematics.ts b/npm/ng-packs/scripts/build-schematics.ts index 0699eb05676..c953a362ca6 100644 --- a/npm/ng-packs/scripts/build-schematics.ts +++ b/npm/ng-packs/scripts/build-schematics.ts @@ -40,6 +40,9 @@ const FILES_TO_COPY_AFTER_BUILD: (FileCopy | string)[] = [ { src: 'src/commands/api/files-model', dest: 'commands/api/files-model' }, { src: 'src/commands/api/files-service', dest: 'commands/api/files-service' }, { src: 'src/commands/api/schema.json', dest: 'commands/api/schema.json' }, + { src: 'src/commands/ssr-add/schema.json', dest: 'commands/ssr-add/schema.json' }, + { src: 'src/commands/ssr-add/files', dest: 'commands/ssr-add/files' }, + { src: 'src/commands/ssr-add/server', dest: 'commands/ssr-add/server' }, { src: 'src/collection.json', dest: 'collection.json' }, 'package.json', 'README.md', From f2d6a627483bcc11af42b6dd34dc3330c86ac769 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Wed, 22 Oct 2025 11:28:52 +0300 Subject: [PATCH 3/7] refactoring --- npm/ng-packs/packages/schematics/src/collection.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/npm/ng-packs/packages/schematics/src/collection.json b/npm/ng-packs/packages/schematics/src/collection.json index 16f148d78a1..b84de19733d 100644 --- a/npm/ng-packs/packages/schematics/src/collection.json +++ b/npm/ng-packs/packages/schematics/src/collection.json @@ -34,7 +34,17 @@ "description": "ABP Change Styles of Theme Schematics", "factory": "./commands/change-theme", "schema": "./commands/change-theme/schema.json" - + }, + "server": { + "factory": "./commands/ssr-add/server", + "description": "Create an Angular server app.", + "schema": "./commands/ssr-add/server/schema.json", + "hidden": true + }, + "ssr-add": { + "description": "ABP SSR Add Schematics", + "factory": "./commands/ssr-add", + "schema": "./commands/ssr-add/schema.json" } } } From 5cf8ba287518a0040b06505d38dd1af7eb0adc06 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Tue, 28 Oct 2025 11:07:17 +0300 Subject: [PATCH 4/7] fix --- .../ngmodule-src/app/app.module.server.ts.template | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template index f5b86f4a6eb..cb42e398a0a 100644 --- a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template @@ -1,5 +1,6 @@ -import { NgModule, inject, PLATFORM_ID, TransferState } from '@angular/core'; +import { NgModule, inject, PLATFORM_ID, TransferState, APP_INITIALIZER } from '@angular/core'; import { ServerModule } from '@angular/platform-server'; +import {isPlatformServer} from "@angular/common"; import { <%= appModuleName %> } from '<%= appModulePath %>'; import { <%= appComponentName %> } from '<%= appComponentPath %>'; From d3be8ebffdad8b9d952e4be405c6df87c811d726 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Tue, 28 Oct 2025 13:22:14 +0300 Subject: [PATCH 5/7] server.ts updated --- .../files/server-builder/server.ts.template | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template index e20cc2e222d..3e64ee03f88 100644 --- a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template @@ -6,6 +6,17 @@ import express from 'express'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> from './main.server'; +import {environment} from './environments/environment'; +import * as oidc from 'openid-client'; + +const ISSUER = new URL(environment.oAuthConfig.issuer); +const CLIENT_ID = environment.oAuthConfig.clientId; +const REDIRECT_URI = environment.oAuthConfig.redirectUri; +const SCOPE = environment.oAuthConfig.scope; + +const config = await oidc.discovery(ISSUER, CLIENT_ID, /* client_secret */ undefined); +const secureCookie = { httpOnly: true, sameSite: 'lax' as const, secure: environment.production, path: '/' }; +const tokenCookie = { ...secureCookie, httpOnly: false }; // The Express app is exported so that it can be used by serverless Functions. export function app(): express.Express { @@ -20,6 +31,119 @@ export function app(): express.Express { server.set('view engine', 'html'); server.set('views', distFolder); + server.use(ServerCookieParser.middleware()); + + const sessions = new Map(); + + server.get('/authorize', async (_req, res) => { + const code_verifier = oidc.randomPKCECodeVerifier(); + const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier); + const state = oidc.randomState(); + + if (_req.query.returnUrl) { + const returnUrl = String(_req.query.returnUrl || null); + res.cookie('returnUrl', returnUrl, { ...secureCookie, maxAge: 5 * 60 * 1000 }); + } + + const sid = crypto.randomUUID(); + sessions.set(sid, { pkce: code_verifier, state }); + res.cookie('sid', sid, secureCookie); + + const url = oidc.buildAuthorizationUrl(config, { + redirect_uri: REDIRECT_URI, + scope: SCOPE, + code_challenge, + code_challenge_method: 'S256', + state, + }); + res.redirect(url.toString()); + }); + + server.get('/logout', async (req, res) => { + try { + const sid = req.cookies.sid; + + if (sid && sessions.has(sid)) { + sessions.delete(sid); + } + + res.clearCookie('sid', secureCookie); + res.clearCookie('access_token', tokenCookie); + res.clearCookie('refresh_token', secureCookie); + res.clearCookie('expires_at', tokenCookie); + res.clearCookie('returnUrl', secureCookie); + + const endSessionEndpoint = config.serverMetadata().end_session_endpoint; + if (endSessionEndpoint) { + const logoutUrl = new URL(endSessionEndpoint); + logoutUrl.searchParams.set('post_logout_redirect_uri', REDIRECT_URI); + logoutUrl.searchParams.set('client_id', CLIENT_ID); + + return res.redirect(logoutUrl.toString()); + } + res.redirect('/'); + + } catch (error) { + console.error('Logout error:', error); + res.status(500).send('Logout error'); + } + }); + + server.get('/', async (req, res, next) => { + try { + const { code, state } = req.query as any; + if (!code || !state) return next(); + + const sid = req.cookies.sid; + const sess = sid && sessions.get(sid); + if (!sess || state !== sess.state) return res.status(400).send('invalid state'); + + const tokenEndpoint = config.serverMetadata().token_endpoint!; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: String(code), + redirect_uri: environment.oAuthConfig.redirectUri, + code_verifier: sess.pkce!, + client_id: CLIENT_ID + }); + + const resp = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!resp.ok) { + const errTxt = await resp.text(); + console.error('token error:', resp.status, errTxt); + return res.status(500).send('token error'); + } + + const tokens = await resp.json(); + + const expiresInSec = + Number(tokens.expires_in ?? tokens.expiresIn ?? 3600); + const skewSec = 60; + const accessExpiresAt = new Date( + Date.now() + Math.max(0, expiresInSec - skewSec) * 1000 + ); + + sessions.set(sid, { ...sess, at: tokens.access_token, refresh: tokens.refresh_token }); + res.cookie('access_token', tokens.access_token, {...tokenCookie, maxAge: accessExpiresAt.getTime()}); + res.cookie('refresh_token', tokens.refresh_token, secureCookie); + res.cookie('expires_at', String(accessExpiresAt.getTime()), tokenCookie); + + const returnUrl = req.cookies?.returnUrl ?? '/'; + res.clearCookie('returnUrl', secureCookie); + + return res.redirect(returnUrl); + } catch (e) { + console.error('OIDC error:', e); + return res.status(500).send('oidc error'); + } + }); + + // Example Express Rest API endpoints // server.get('/api/{*splat}', (req, res) => { }); // Serve static files from /browser From cf854ca24bf1359f016c5cde5773ef7984785035 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Thu, 30 Oct 2025 11:42:03 +0300 Subject: [PATCH 6/7] refactoring --- .../files/server-builder/server.ts.template | 27 ++++++++++++++++--- .../app/app.module.server.ts.template | 20 ++++++-------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template index 3e64ee03f88..e3102c37271 100644 --- a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template @@ -9,15 +9,25 @@ import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> fr import {environment} from './environments/environment'; import * as oidc from 'openid-client'; +if (environment.production === false) { + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; +} + const ISSUER = new URL(environment.oAuthConfig.issuer); const CLIENT_ID = environment.oAuthConfig.clientId; const REDIRECT_URI = environment.oAuthConfig.redirectUri; const SCOPE = environment.oAuthConfig.scope; +// @ts-ignore +const CLIENT_SECRET = environment.oAuthConfig.clientSecret || undefined; -const config = await oidc.discovery(ISSUER, CLIENT_ID, /* client_secret */ undefined); +let config: Awaited>; const secureCookie = { httpOnly: true, sameSite: 'lax' as const, secure: environment.production, path: '/' }; const tokenCookie = { ...secureCookie, httpOnly: false }; +async function initializeOIDC() { + config = await oidc.discovery(ISSUER, CLIENT_ID, CLIENT_SECRET); +} + // The Express app is exported so that it can be used by serverless Functions. export function app(): express.Express { const server = express(); @@ -73,6 +83,10 @@ export function app(): express.Express { res.clearCookie('expires_at', tokenCookie); res.clearCookie('returnUrl', secureCookie); + if (!config) { + return res.redirect('/'); + } + const endSessionEndpoint = config.serverMetadata().end_session_endpoint; if (endSessionEndpoint) { const logoutUrl = new URL(endSessionEndpoint); @@ -162,9 +176,12 @@ export function app(): express.Express { documentFilePath: indexHtml, url: `${protocol}://${headers.host}${originalUrl}`, publicPath: distFolder, - providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }, { provide: 'cookies', useValue: JSON.stringify(req.headers.cookie) }], + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], }) - .then((html) => res.send(html)) + .then((html) => { + res.cookie('ssr-init', 'true', {...secureCookie, httpOnly: false}); + return res.send(html) + }) .catch((err) => next(err)); }); @@ -174,6 +191,10 @@ export function app(): express.Express { function run(): void { const port = process.env['PORT'] || 4000; + console.log('🔐 Initializing OIDC configuration...'); + await initializeOIDC(); + console.log('✅ OIDC configuration loaded'); + // Start up the Node server const server = app(); server.listen(port, (error) => { diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template index cb42e398a0a..3f2b4f379cd 100644 --- a/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/server/files/server-builder/ngmodule-src/app/app.module.server.ts.template @@ -1,4 +1,4 @@ -import { NgModule, inject, PLATFORM_ID, TransferState, APP_INITIALIZER } from '@angular/core'; +import {NgModule, inject, PLATFORM_ID, TransferState, provideAppInitializer} from '@angular/core'; import { ServerModule } from '@angular/platform-server'; import {isPlatformServer} from "@angular/common"; @@ -12,17 +12,13 @@ import { SSR_FLAG } from '@abp/ng.core'; ServerModule, ], providers: [ - { - provide: APP_INITIALIZER, - useFactory: () => { - const platformId = inject(PLATFORM_ID); - const transferState = inject(TransferState); - if (isPlatformServer(platformId)) { - transferState.set(SSR_FLAG, true); - } - }, - multi: true - } + provideAppInitializer(() => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }), ], bootstrap: [<%= appComponentName %>], }) From b96b1709007e8cf14b968b46ba8f0b9fa0fe20f1 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Thu, 30 Oct 2025 21:20:01 +0300 Subject: [PATCH 7/7] refactoring --- .../services/server-token-storage.service.ts | 7 +++-- .../packages/oauth/src/lib/tokens/cookies.ts | 3 ++ .../packages/oauth/src/lib/tokens/index.ts | 1 + .../files/server-builder/server.ts.template | 9 +++--- .../schematics/src/commands/ssr-add/index.ts | 28 +++++++++++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 npm/ng-packs/packages/oauth/src/lib/tokens/cookies.ts diff --git a/npm/ng-packs/packages/oauth/src/lib/services/server-token-storage.service.ts b/npm/ng-packs/packages/oauth/src/lib/services/server-token-storage.service.ts index 0926b749d8e..bbbdcae5435 100644 --- a/npm/ng-packs/packages/oauth/src/lib/services/server-token-storage.service.ts +++ b/npm/ng-packs/packages/oauth/src/lib/services/server-token-storage.service.ts @@ -1,13 +1,16 @@ -import { Inject, Injectable, Optional } from '@angular/core'; +import { inject, Inject, Injectable, Optional } from '@angular/core'; import { OAuthStorage } from 'angular-oauth2-oidc'; import { REQUEST } from '@angular/core'; +import { COOKIES } from '../tokens'; @Injectable({ providedIn: null }) export class ServerTokenStorageService implements OAuthStorage { private cookies = new Map(); + // For server builders where REQUEST injection is not possible, cookies can be provided via COOKIES token + private cookiesStr = inject(COOKIES, { optional: true }); constructor(@Optional() @Inject(REQUEST) private req: Request | null) { - const cookieHeader = this.req?.headers.get('cookie') ?? ''; + const cookieHeader = this.req?.headers.get('cookie') ?? this.cookiesStr ?? ''; for (const part of cookieHeader.split(';')) { const i = part.indexOf('='); if (i > -1) { diff --git a/npm/ng-packs/packages/oauth/src/lib/tokens/cookies.ts b/npm/ng-packs/packages/oauth/src/lib/tokens/cookies.ts new file mode 100644 index 00000000000..f6cf608bf6b --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/tokens/cookies.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const COOKIES = new InjectionToken('COOKIES'); diff --git a/npm/ng-packs/packages/oauth/src/lib/tokens/index.ts b/npm/ng-packs/packages/oauth/src/lib/tokens/index.ts index 1ec58f54b32..cce552af078 100644 --- a/npm/ng-packs/packages/oauth/src/lib/tokens/index.ts +++ b/npm/ng-packs/packages/oauth/src/lib/tokens/index.ts @@ -1 +1,2 @@ export * from './auth-flow-strategy'; +export * from './cookies'; diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template index e3102c37271..046731c7d8c 100644 --- a/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/files/server-builder/server.ts.template @@ -2,12 +2,14 @@ import 'zone.js/node'; import { APP_BASE_HREF } from '@angular/common'; import { CommonEngine } from '@angular/ssr/node'; -import express from 'express'; +import * as express from 'express'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> from './main.server'; import {environment} from './environments/environment'; import * as oidc from 'openid-client'; +import { ServerCookieParser } from '@abp/ng.core'; +import {COOKIES} from "@abp/ng.oauth"; if (environment.production === false) { process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; @@ -176,7 +178,7 @@ export function app(): express.Express { documentFilePath: indexHtml, url: `${protocol}://${headers.host}${originalUrl}`, publicPath: distFolder, - providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }, { provide: COOKIES, useValue: JSON.stringify(req.headers.cookie) }], }) .then((html) => { res.cookie('ssr-init', 'true', {...secureCookie, httpOnly: false}); @@ -188,7 +190,7 @@ export function app(): express.Express { return server; } -function run(): void { +async function run(): Promise { const port = process.env['PORT'] || 4000; console.log('🔐 Initializing OIDC configuration...'); @@ -201,7 +203,6 @@ function run(): void { if (error) { throw error; } - console.log(`Node Express server listening on http://localhost:${port}`); }); } diff --git a/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts b/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts index 17e4bb2912c..4f5d466384b 100644 --- a/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/ssr-add/index.ts @@ -211,6 +211,33 @@ function updateRootTsConfigRule(options: SSROptions): Rule { }; } +export function updateIndexHtml(options: SSROptions): Rule { + return async (host: Tree) => { + const workspace = await getWorkspace(host); + const project = workspace.projects.get(options.project); + + if (!project) { + return; + } + + const buildOptions = project.targets.get('build')?.options; + const indexPath = buildOptions?.index as string; + + if (!indexPath || !host.exists(indexPath)) { + return; + } + + const buffer = host.read(indexPath); + if (!buffer) return; + const content = buffer.toString('utf-8'); + + const loaderDiv = `
`; + let updatedContent = content.replace(loaderDiv, ''); + host.overwrite(indexPath, updatedContent); + }; +} + + function updateApplicationBuilderWorkspaceConfigRule( projectSourceRoot: string, options: SSROptions, @@ -439,6 +466,7 @@ export default function (options: SSROptions): Rule { addServerFile(sourceRoot, options, isStandalone), addScriptsRule(options, usingApplicationBuilder), addDependencies(options, usingApplicationBuilder), + updateIndexHtml(options), ]); }; }