diff --git a/angular.json b/angular.json index 3856bdd..8304e66 100644 --- a/angular.json +++ b/angular.json @@ -44,6 +44,7 @@ "builder": "@angular/build:application", "options": { "browser": "src/main.ts", + "server": "src/main.server.ts", "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": [ @@ -55,6 +56,12 @@ "styles": ["src/styles.scss"], "stylePreprocessorOptions": { "includePaths": ["src/styles"] + }, + "ssr": { + "entry": "server.ts" + }, + "prerender": { + "routesFile": "prerender-routes.txt" } }, "configurations": { diff --git a/package-lock.json b/package-lock.json index f2d0258..002163a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", + "@angular/platform-server": "^21.2.19", "@angular/router": "^21.2.0", + "@angular/ssr": "^21.2.20", "@types/leaflet": "^1.9.17", "chart.js": "^4.4.0", "leaflet": "^1.9.4", @@ -29,6 +31,7 @@ "@angular/cli": "^21.2.14", "@angular/compiler-cli": "^21.2.0", "@types/jsdom": "^21.1.7", + "@types/node": "^26.1.2", "@vitest/coverage-v8": "^4.0.8", "jsdom": "^26.1.0", "prettier": "^3.8.1", @@ -603,6 +606,26 @@ } } }, + "node_modules/@angular/platform-server": { + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-21.2.19.tgz", + "integrity": "sha512-UVyUIj04LRBoi4KfvxeZYPohzh80nSqamxpb8uzsYFKVSYdZSHDiFaE6OOzj0YST1rs2/LC92cPE2TY7IQIdew==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0", + "xhr2": "^0.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.19", + "@angular/compiler": "21.2.19", + "@angular/core": "21.2.19", + "@angular/platform-browser": "21.2.19", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/router": { "version": "21.2.16", "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.16.tgz", @@ -621,6 +644,26 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/ssr": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.20.tgz", + "integrity": "sha512-MZn7Hssm2EYaQ8zWGD+o2fzVs4BLGHLXSg2L7JBmAjlXhSyJeBYw2PUOzSX4gqCNF6wR8ikmJJTel0q8Bo6oow==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/platform-server": "^21.0.0", + "@angular/router": "^21.0.0" + }, + "peerDependenciesMeta": { + "@angular/platform-server": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1060,43 +1103,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", - "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -8732,6 +8738,15 @@ } } }, + "node_modules/xhr2": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", + "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 787afb6..9457745 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,16 @@ "private": true, "packageManager": "npm@11.11.0", "dependencies": { + "@angular/cdk": "^21.2.0", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", + "@angular/platform-server": "^21.2.19", "@angular/router": "^21.2.0", + "@angular/ssr": "^21.2.20", "@types/leaflet": "^1.9.17", - "@angular/cdk": "^21.2.0", "chart.js": "^4.4.0", "leaflet": "^1.9.4", "ng2-charts": "^10.0.0", @@ -34,11 +36,12 @@ "@angular/build": "^21.2.14", "@angular/cli": "^21.2.14", "@angular/compiler-cli": "^21.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "^26.1.2", "@vitest/coverage-v8": "^4.0.8", "jsdom": "^26.1.0", "prettier": "^3.8.1", "typescript": "~5.9.2", - "vitest": "^4.0.8", - "@types/jsdom": "^21.1.7" + "vitest": "^4.0.8" } } diff --git a/prerender-routes.txt b/prerender-routes.txt new file mode 100644 index 0000000..8421a92 --- /dev/null +++ b/prerender-routes.txt @@ -0,0 +1,5 @@ +/ +/about +/schedule +/events +/contact diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..3d4a2e1 --- /dev/null +++ b/server.ts @@ -0,0 +1,41 @@ +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; +import * as http from 'node:http'; + +const ngApp = new AngularNodeAppEngine(); + +/** + * Handle incoming requests using the Angular SSR engine. + * Prerendered routes are served as static HTML at build time. + * Dynamic routes fall back to server-side rendering. + */ +export default createNodeRequestHandler(async (req, res, next) => { + try { + const response = await ngApp.handle(req); + if (response) { + await writeResponseToNodeResponse(response, res); + } else { + next(); + } + } catch (error) { + next(error); + } +}); + +if (isMainModule(import.meta.url)) { + const httpServer = http.createServer(); + httpServer.on('request', async (req: http.IncomingMessage, res: http.ServerResponse) => { + const handler = (await import('./server.js')).default; + handler(req, res, () => { + res.statusCode = 404; + res.end('Not Found'); + }); + }); + httpServer.listen(4200, () => { + console.log(`Node SSR server listening on http://localhost:4200`); + }); +} diff --git a/src/app/app.server.routes.ts b/src/app/app.server.routes.ts new file mode 100644 index 0000000..0a729c4 --- /dev/null +++ b/src/app/app.server.routes.ts @@ -0,0 +1,19 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +/** + * Server routes configuration for Angular SSR. + * + * The five public routes are prerendered at build time (SSG) for optimal SEO. + * Authenticated routes (/login, /admin) use SSR for dynamic rendering. + * All other routes fall back to SSR. + */ +export const serverRoutes: ServerRoute[] = [ + { path: '', renderMode: RenderMode.Prerender }, + { path: 'about', renderMode: RenderMode.Prerender }, + { path: 'schedule', renderMode: RenderMode.Prerender }, + { path: 'events', renderMode: RenderMode.Prerender }, + { path: 'contact', renderMode: RenderMode.Prerender }, + { path: 'login', renderMode: RenderMode.Server }, + { path: 'admin', renderMode: RenderMode.Server }, + { path: '**', renderMode: RenderMode.Server }, +]; diff --git a/src/app/app.ts b/src/app/app.ts index 80b761b..6eea0d3 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,4 +1,5 @@ -import { Component, computed, effect, inject, OnInit, OnDestroy } from '@angular/core'; +import { Component, computed, effect, inject, OnInit, OnDestroy, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { NavbarComponent } from './navbar/navbar.component'; import { MediaPlayerComponent } from './media-player/media-player.component'; @@ -17,6 +18,7 @@ import { SeoService } from './services/seo.service'; export class App implements OnInit, OnDestroy { private stationConfig = inject(StationConfigService); private seoService = inject(SeoService); + private platformId = inject(PLATFORM_ID); readonly hasStream = computed(() => { const cfg = this.stationConfig.config(); @@ -27,6 +29,9 @@ export class App implements OnInit, OnDestroy { private resizeObserver: ResizeObserver | null = null; constructor() { + // These DOM operations only run in the browser. + if (!isPlatformBrowser(this.platformId)) return; + // Update favicon when station config changes. effect(() => { const config = this.stationConfig.config(); @@ -46,6 +51,9 @@ export class App implements OnInit, OnDestroy { } ngOnInit(): void { + // These DOM operations only run in the browser. + if (!isPlatformBrowser(this.platformId)) return; + // Start watching router for automatic SEO metadata updates per route. this.seoService.watchRouter(); // Handle the initial route (NavigationEnd already fired before ngOnInit). @@ -64,7 +72,9 @@ export class App implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.resizeObserver?.disconnect(); + if (isPlatformBrowser(this.platformId)) { + this.resizeObserver?.disconnect(); + } } private updateNavbarHeight(): void { diff --git a/src/app/services/event.service.ts b/src/app/services/event.service.ts index 5e31975..e9a3138 100644 --- a/src/app/services/event.service.ts +++ b/src/app/services/event.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { Event } from '../interfaces/event'; import { getAppConfig } from './app-config.service'; @@ -16,7 +17,12 @@ export class EventService { getEvents(active?: boolean): Observable { let params = new HttpParams(); if (active !== undefined) params = params.set('active', String(active)); - return this.http.get(this.baseUrl, { params }); + return this.http.get(this.baseUrl, { params }).pipe( + catchError((err) => { + console.warn('EventService: Failed to load events, returning empty array.', err); + return of([]); + }), + ); } getEvent(id: number): Observable { diff --git a/src/app/services/show.service.ts b/src/app/services/show.service.ts index 4b84201..f2dffc7 100644 --- a/src/app/services/show.service.ts +++ b/src/app/services/show.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { Show } from '../interfaces/show'; import { getAppConfig } from './app-config.service'; @@ -41,7 +42,12 @@ export class ShowService { private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`; getShows(): Observable { - return this.http.get(this.baseUrl); + return this.http.get(this.baseUrl).pipe( + catchError((err) => { + console.warn('ShowService: Failed to load shows, returning empty array.', err); + return of([]); + }), + ); } getShow(id: number): Observable { diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts index 93bcd49..195c758 100644 --- a/src/app/services/theme.service.ts +++ b/src/app/services/theme.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { StationConfig } from '../interfaces/station-config'; @@ -32,11 +33,16 @@ function gradient(from: string, mid: string, to: string): string { providedIn: 'root', }) export class ThemeService { + private platformId = inject(PLATFORM_ID); + /** * Apply color theme from StationConfig as CSS custom properties on `:root`. * Also generates derived gradient variables. + * Skips silently on the server (SSR) — CSS vars are set in the browser after hydration. */ apply(config: StationConfig): void { + if (!isPlatformBrowser(this.platformId)) return; + const root = document.documentElement.style; for (const { field, prop } of COLOR_MAP) { diff --git a/src/app/structured-data/structured-data.component.ts b/src/app/structured-data/structured-data.component.ts index e6c4d0d..ba1d65e 100644 --- a/src/app/structured-data/structured-data.component.ts +++ b/src/app/structured-data/structured-data.component.ts @@ -1,4 +1,5 @@ -import { Component, effect, inject } from '@angular/core'; +import { Component, effect, inject, PLATFORM_ID, OnDestroy } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { StructuredDataService } from '../services/structured-data.service'; /** @@ -16,10 +17,14 @@ import { StructuredDataService } from '../services/structured-data.service'; standalone: true, template: '', // renders nothing in the body — writes to }) -export class StructuredDataComponent { +export class StructuredDataComponent implements OnDestroy { private service = inject(StructuredDataService); + private platformId = inject(PLATFORM_ID); constructor() { + // Only manipulate DOM in the browser; skip on the server. + if (!isPlatformBrowser(this.platformId)) return; + effect(() => { const entries = this.service.structuredData(); this.renderEntries(entries); @@ -27,6 +32,7 @@ export class StructuredDataComponent { } ngOnDestroy(): void { + if (!isPlatformBrowser(this.platformId)) return; this.clearTags(); } diff --git a/src/main.server.ts b/src/main.server.ts new file mode 100644 index 0000000..ee77f70 --- /dev/null +++ b/src/main.server.ts @@ -0,0 +1,24 @@ +import { APP_ID } from '@angular/core'; +import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; + +import { appConfig } from './app/app.config'; +import { App } from './app/app'; +import { serverRoutes } from './app/app.server.routes'; + +const serverAppConfig = { + ...appConfig, + providers: [ + ...appConfig.providers, + { provide: APP_ID, useValue: 'server' }, + provideServerRendering(withRoutes(serverRoutes)), + ], +}; + +/** + * Bootstrap function for server-side rendering. + * Exports a function that accepts BootstrapContext and returns ApplicationRef, + * as required by @angular/ssr's CommonEngine bootstrap signature. + */ +export default (ctx: BootstrapContext) => + bootstrapApplication(App, serverAppConfig, ctx);