diff --git a/templates/app/angular/angular.json b/templates/app/angular/angular.json index 7a6c2413d3..88ddf85212 100644 --- a/templates/app/angular/angular.json +++ b/templates/app/angular/angular.json @@ -21,7 +21,7 @@ "build": { "builder": "@angular-devkit/build-angular:browser", "options": { - "outputPath": "dist/MyProjectName", + "outputPath": "dist/MyProjectName/browser", "index": "src/index.html", "main": "src/main.ts", "polyfills": "src/polyfills.ts", @@ -183,6 +183,67 @@ "options": { "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] } + }, + "server": { + "builder": "@angular-devkit/build-angular:server", + "options": { + "outputPath": "dist/MyProjectName/server", + "main": "src/server.ts", + "tsConfig": "tsconfig.server.json", + "inlineStyleLanguage": "scss" + }, + "configurations": { + "production": { + "outputHashing": "media", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "sourceMap": true, + "extractLicenses": false, + "vendorChunk": true + } + }, + "defaultConfiguration": "production" + }, + "serve-ssr": { + "builder": "@angular-devkit/build-angular:ssr-dev-server", + "configurations": { + "development": { + "browserTarget": "MyProjectName:build:development", + "serverTarget": "MyProjectName:server:development" + }, + "production": { + "browserTarget": "MyProjectName:build:production", + "serverTarget": "MyProjectName:server:production" + } + }, + "defaultConfiguration": "development" + }, + "prerender": { + "builder": "@angular-devkit/build-angular:prerender", + "options": { + "routes": [ + "/" + ] + }, + "configurations": { + "production": { + "browserTarget": "MyProjectName:build:production", + "serverTarget": "MyProjectName:server:production" + }, + "development": { + "browserTarget": "MyProjectName:build:development", + "serverTarget": "MyProjectName:server:development" + } + }, + "defaultConfiguration": "production" } } } diff --git a/templates/app/angular/package.json b/templates/app/angular/package.json index 5e3e8747a9..e76912e04c 100644 --- a/templates/app/angular/package.json +++ b/templates/app/angular/package.json @@ -8,7 +8,11 @@ "build:prod": "ng build --configuration production", "watch": "ng build --watch --configuration development", "test": "ng test", - "lint": "ng lint" + "lint": "ng lint", + "dev:ssr": "ng run MyProjectName:serve-ssr", + "serve:ssr": "node dist/MyProjectName/server/main.js", + "build:ssr": "ng build && ng run MyProjectName:server", + "prerender": "ng run MyProjectName:prerender" }, "private": true, "dependencies": { @@ -29,8 +33,11 @@ "@angular/localize": "~19.1.0", "@angular/platform-browser": "~19.1.0", "@angular/platform-browser-dynamic": "~19.1.0", + "@angular/platform-server": "~19.1.0", "@angular/router": "~19.1.0", + "@angular/ssr": "^19.1.9", "bootstrap-icons": "~1.8.0", + "express": "^4.18.2", "rxjs": "~7.8.0", "tslib": "^2.0.0", "zone.js": "~0.15.0" @@ -46,10 +53,12 @@ "@angular/cli": "~19.1.0", "@angular/compiler-cli": "~19.1.0", "@angular/language-service": "~19.1.0", + "@types/express": "^4.17.17", "@types/jasmine": "~3.6.0", - "@types/node": "^12.11.1", + "@types/node": "^18.18.0", "@typescript-eslint/eslint-plugin": "7.16.0", "@typescript-eslint/parser": "7.16.0", + "browser-sync": "^3.0.0", "eslint": "^8.0.0", "jasmine-core": "~4.0.0", "karma": "~6.3.0", @@ -59,4 +68,4 @@ "karma-jasmine-html-reporter": "^1.7.0", "typescript": "~5.6.0" } -} +} \ No newline at end of file diff --git a/templates/app/angular/src/app/app.config.server.ts b/templates/app/angular/src/app/app.config.server.ts new file mode 100644 index 0000000000..b4d57c9423 --- /dev/null +++ b/templates/app/angular/src/app/app.config.server.ts @@ -0,0 +1,11 @@ +import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; +import { appConfig } from './app.config'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering() + ] +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/templates/app/angular/src/app/app.config.ts b/templates/app/angular/src/app/app.config.ts index 0d7e64c80c..360141db3c 100644 --- a/templates/app/angular/src/app/app.config.ts +++ b/templates/app/angular/src/app/app.config.ts @@ -17,30 +17,37 @@ import { provideFeatureManagementConfig } from '@abp/ng.feature-management'; import {ThemeLeptonXModule} from "@abp/ng.theme.lepton-x"; import {SideMenuLayoutModule} from "@abp/ng.theme.lepton-x/layouts"; import {AccountLayoutModule} from "@abp/ng.theme.lepton-x/account"; +import {provideClientHydration, withEventReplay, withHttpTransferCacheOptions} from '@angular/platform-browser'; +import {provideServerRouting} from "@angular/ssr"; +import {provideServerRendering} from "@angular/platform-server"; +import {serverRoutes} from "./app.routes.server"; -export const appConfig: ApplicationConfig = { - providers: [ - provideRouter(appRoutes), - APP_ROUTE_PROVIDER, - provideAbpCore( - withOptions({ - environment, - registerLocaleFn: registerLocale(), - }) - ), - provideAbpOAuth(), - provideAbpThemeShared(), - provideSettingManagementConfig(), - provideAccountConfig(), - provideIdentityConfig(), - provideTenantManagementConfig(), - provideFeatureManagementConfig(), - provideAnimations(), - provideAbpCore(), - importProvidersFrom([ - ThemeLeptonXModule.forRoot(), - SideMenuLayoutModule.forRoot(), - AccountLayoutModule.forRoot(), - ]) - ], +export function createAppConfig(ssr: boolean): ApplicationConfig { + return { + providers: [ + provideRouter(appRoutes), + APP_ROUTE_PROVIDER, + provideAbpCore( + withOptions({ + environment, + registerLocaleFn: registerLocale(), + }) + ), + provideAbpOAuth({ssr}), + provideAbpThemeShared(), + provideSettingManagementConfig(), + provideAccountConfig(), + provideIdentityConfig(), + provideTenantManagementConfig(), + provideFeatureManagementConfig(), + provideAnimations(), + provideAbpCore(), + importProvidersFrom([ + ThemeLeptonXModule.forRoot(), + SideMenuLayoutModule.forRoot(), + AccountLayoutModule.forRoot(), + ]), provideClientHydration(withEventReplay(), withHttpTransferCacheOptions({})), + ...(ssr ? [provideServerRendering(), provideServerRouting(serverRoutes)] : []), + ], + } }; diff --git a/templates/app/angular/src/app/app.routes.server.ts b/templates/app/angular/src/app/app.routes.server.ts new file mode 100644 index 0000000000..eb7e2013a6 --- /dev/null +++ b/templates/app/angular/src/app/app.routes.server.ts @@ -0,0 +1,8 @@ +import {RenderMode, ServerRoute} from "@angular/ssr"; + +export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Server + } +]; diff --git a/templates/app/angular/src/main.server.ts b/templates/app/angular/src/main.server.ts new file mode 100644 index 0000000000..8ec2a895c8 --- /dev/null +++ b/templates/app/angular/src/main.server.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import {createAppConfig} from "./app/app.config"; + +const bootstrap = () => bootstrapApplication(AppComponent, createAppConfig(true)); + +export default bootstrap; diff --git a/templates/app/angular/src/main.ts b/templates/app/angular/src/main.ts index 7180ec1a32..cb6d2ca149 100644 --- a/templates/app/angular/src/main.ts +++ b/templates/app/angular/src/main.ts @@ -1,5 +1,5 @@ import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; +import {createAppConfig} from './app/app.config'; import { AppComponent } from './app/app.component'; -bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); +bootstrapApplication(AppComponent, createAppConfig(false)).catch(err => console.error(err)); diff --git a/templates/app/angular/src/server.ts b/templates/app/angular/src/server.ts new file mode 100644 index 0000000000..3051e954ae --- /dev/null +++ b/templates/app/angular/src/server.ts @@ -0,0 +1,69 @@ +import 'zone.js/node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr/node'; +import * as express from 'express'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import bootstrap 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(), 'dist/MyProjectName/browser'); + 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/**', (req, res) => { }); + // Serve static files from /browser + server.get('*.*', express.static(distFolder, { + maxAge: '1y' + })); + + // All regular routes use the Angular engine + server.get('*', (req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; + + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: distFolder, + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], + }) + .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, () => { + 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 bootstrap; diff --git a/templates/app/angular/tsconfig.json b/templates/app/angular/tsconfig.json index 9395506033..8e339cf400 100644 --- a/templates/app/angular/tsconfig.json +++ b/templates/app/angular/tsconfig.json @@ -12,6 +12,7 @@ "importHelpers": true, "target": "ES2022", "module": "es2020", + "skipLibCheck": true, "lib": [ "es2018", "dom" diff --git a/templates/app/angular/tsconfig.server.json b/templates/app/angular/tsconfig.server.json new file mode 100644 index 0000000000..1166d1a0aa --- /dev/null +++ b/templates/app/angular/tsconfig.server.json @@ -0,0 +1,16 @@ +/* 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": "./tsconfig.app.json", + "compilerOptions": { + "outDir": "./out-tsc/server", + "types": [ + "node", + "@angular/localize" + ] + }, + "files": [ + "src/main.server.ts", + "src/server.ts" + ] +}