fix(front-end): seo improvements with SSR
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s

This commit is contained in:
2026-03-14 15:13:54 +01:00
parent 576380e9a0
commit ba49463ee7
4 changed files with 96 additions and 40 deletions

View File

@@ -1,47 +1,11 @@
import { HttpInterceptorFn } from '@angular/common/http'; import { HttpInterceptorFn } from '@angular/common/http';
import { inject, REQUEST } from '@angular/core'; import { inject, REQUEST } from '@angular/core';
import { RequestLike, resolveRequestOrigin } from '../../../core/request-origin';
type RequestLike = {
protocol?: string;
get?: (name: string) => string | undefined;
headers?: Record<string, string | string[] | undefined>;
};
function isAbsoluteUrl(url: string): boolean { function isAbsoluteUrl(url: string): boolean {
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); 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 { function normalizeRelativePath(url: string): string {
const withoutDot = url.replace(/^\.\//, ''); const withoutDot = url.replace(/^\.\//, '');
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; 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 request = inject(REQUEST, { optional: true }) as RequestLike | null;
const origin = resolveOrigin(request); const origin = resolveRequestOrigin(request);
if (!origin) { if (!origin) {
return next(req); return next(req);
} }

View File

@@ -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');
});
});

View File

@@ -0,0 +1,50 @@
export type RequestLike = {
protocol?: string;
get?: (name: string) => string | undefined;
headers?: Record<string, string | string[] | undefined>;
};
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}`;
}

View File

@@ -4,6 +4,7 @@ import express from 'express';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import bootstrap from './main.server'; import bootstrap from './main.server';
import { resolveRequestOrigin } from './core/request-origin';
const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser'); const browserDistFolder = resolve(serverDistFolder, '../browser');
@@ -39,13 +40,14 @@ app.get(
* Handle all other requests by rendering the Angular application. * Handle all other requests by rendering the Angular application.
*/ */
app.get('**', (req, res, next) => { app.get('**', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req; const { originalUrl, baseUrl } = req;
const origin = resolveRequestOrigin(req);
commonEngine commonEngine
.render({ .render({
bootstrap, bootstrap,
documentFilePath: indexHtml, documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`, url: `${origin ?? 'http://localhost:4000'}${originalUrl}`,
publicPath: browserDistFolder, publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
}) })