From ba49463ee71d00edd26da4496b8017cbc24848c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sat, 14 Mar 2026 15:13:54 +0100 Subject: [PATCH] fix(front-end): seo improvements with SSR --- .../interceptors/server-origin.interceptor.ts | 40 +-------------- frontend/src/core/request-origin.spec.ts | 40 +++++++++++++++ frontend/src/core/request-origin.ts | 50 +++++++++++++++++++ frontend/src/server.ts | 6 ++- 4 files changed, 96 insertions(+), 40 deletions(-) create mode 100644 frontend/src/core/request-origin.spec.ts create mode 100644 frontend/src/core/request-origin.ts diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 55fe6f8..70767ab 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -1,47 +1,11 @@ import { HttpInterceptorFn } from '@angular/common/http'; import { inject, REQUEST } from '@angular/core'; - -type RequestLike = { - protocol?: string; - get?: (name: string) => string | undefined; - headers?: Record; -}; +import { RequestLike, resolveRequestOrigin } from '../../../core/request-origin'; function isAbsoluteUrl(url: string): boolean { return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); } -function firstHeaderValue(value: string | string[] | undefined): string | null { - if (Array.isArray(value)) { - return value[0] ?? null; - } - return typeof value === 'string' ? value : null; -} - -function resolveOrigin(request: RequestLike | null): string | null { - if (!request) { - return null; - } - - const host = - request.get?.('host') ?? - firstHeaderValue(request.headers?.['host']) ?? - firstHeaderValue(request.headers?.['x-forwarded-host']); - if (!host) { - return null; - } - - const forwardedProtoRaw = firstHeaderValue( - request.headers?.['x-forwarded-proto'], - ); - const forwardedProto = forwardedProtoRaw - ?.split(',') - .map((part) => part.trim().toLowerCase()) - .find(Boolean); - const protocol = forwardedProto || request.protocol || 'http'; - return `${protocol}://${host}`; -} - function normalizeRelativePath(url: string): string { const withoutDot = url.replace(/^\.\//, ''); return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; @@ -53,7 +17,7 @@ export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { } const request = inject(REQUEST, { optional: true }) as RequestLike | null; - const origin = resolveOrigin(request); + const origin = resolveRequestOrigin(request); if (!origin) { return next(req); } diff --git a/frontend/src/core/request-origin.spec.ts b/frontend/src/core/request-origin.spec.ts new file mode 100644 index 0000000..d906d22 --- /dev/null +++ b/frontend/src/core/request-origin.spec.ts @@ -0,0 +1,40 @@ +import { resolveRequestOrigin } from './request-origin'; + +describe('resolveRequestOrigin', () => { + it('prefers forwarded host and protocol when present', () => { + expect( + resolveRequestOrigin({ + protocol: 'http', + headers: { + host: 'internal:4000', + 'x-forwarded-host': '3d-fab.ch', + 'x-forwarded-proto': 'https', + }, + }), + ).toBe('https://3d-fab.ch'); + }); + + it('falls back to request protocol and host', () => { + expect( + resolveRequestOrigin({ + protocol: 'http', + headers: { + host: 'localhost:4000', + }, + }), + ).toBe('http://localhost:4000'); + }); + + it('uses the first forwarded value when proxies append multiple entries', () => { + expect( + resolveRequestOrigin({ + protocol: 'http', + headers: { + host: 'internal:4000', + 'x-forwarded-host': '3d-fab.ch, proxy.local', + 'x-forwarded-proto': 'https, http', + }, + }), + ).toBe('https://3d-fab.ch'); + }); +}); diff --git a/frontend/src/core/request-origin.ts b/frontend/src/core/request-origin.ts new file mode 100644 index 0000000..033edac --- /dev/null +++ b/frontend/src/core/request-origin.ts @@ -0,0 +1,50 @@ +export type RequestLike = { + protocol?: string; + get?: (name: string) => string | undefined; + headers?: Record; +}; + +function firstHeaderValue( + value: string | string[] | undefined, +): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + return typeof value === 'string' ? value : null; +} + +function firstForwardedValue( + value: string | string[] | undefined, +): string | null { + const raw = firstHeaderValue(value); + if (!raw) { + return null; + } + + return ( + raw + .split(',') + .map((part) => part.trim()) + .find(Boolean) ?? null + ); +} + +export function resolveRequestOrigin(request: RequestLike | null): string | null { + if (!request) { + return null; + } + + const host = + firstForwardedValue(request.headers?.['x-forwarded-host']) ?? + request.get?.('host') ?? + firstHeaderValue(request.headers?.['host']); + if (!host) { + return null; + } + + const forwardedProto = firstForwardedValue( + request.headers?.['x-forwarded-proto'], + )?.toLowerCase(); + const protocol = forwardedProto || request.protocol || 'http'; + return `${protocol}://${host}`; +} diff --git a/frontend/src/server.ts b/frontend/src/server.ts index 34d8bc5..04df896 100644 --- a/frontend/src/server.ts +++ b/frontend/src/server.ts @@ -4,6 +4,7 @@ import express from 'express'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import bootstrap from './main.server'; +import { resolveRequestOrigin } from './core/request-origin'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); @@ -39,13 +40,14 @@ app.get( * Handle all other requests by rendering the Angular application. */ app.get('**', (req, res, next) => { - const { protocol, originalUrl, baseUrl, headers } = req; + const { originalUrl, baseUrl } = req; + const origin = resolveRequestOrigin(req); commonEngine .render({ bootstrap, documentFilePath: indexHtml, - url: `${protocol}://${headers.host}${originalUrl}`, + url: `${origin ?? 'http://localhost:4000'}${originalUrl}`, publicPath: browserDistFolder, providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], })