From 1d80a0e7eca6df3da34e9a26132008cfd3beb206 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Tue, 15 Sep 2026 08:56:49 +0200 Subject: [PATCH] fix(shared): express server user redirects --- .../frontend-agent-console/src/server.ts | 9 ++ .../src/delegating-server.ts | 1 + .../frontend-billing-console/src/server.ts | 9 ++ .../src/delegating-server.ts | 1 + .../src/delegating-server.ts | 1 + .../frontend-docs/src/delegating-server.ts | 1 + .../src/delegating-server.ts | 1 + .../frontend/util-express-server/src/index.ts | 1 + .../src/lib/create-delegating-server.spec.ts | 95 +++++++++++++++++++ .../src/lib/create-delegating-server.ts | 32 ++++++- 10 files changed, 150 insertions(+), 1 deletion(-) diff --git a/apps/agenstra/frontend-agent-console/src/server.ts b/apps/agenstra/frontend-agent-console/src/server.ts index 9399029fe..37c01ae1b 100644 --- a/apps/agenstra/frontend-agent-console/src/server.ts +++ b/apps/agenstra/frontend-agent-console/src/server.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { + buildRootRedirectLocation, createCachedStaticFile, createMemoryStaticMiddleware, createSecurityHeadersMiddleware, @@ -356,6 +357,14 @@ app.get('*', (req, res) => { } if (!getLocaleFromPath(req)) { + const requestUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + if (requestUrl.pathname === '/') { + res.redirect(301, buildRootRedirectLocation(locale, '/', requestUrl.search)); + + return; + } + res.redirect(302, `/${locale}${req.url}`); return; diff --git a/apps/agenstra/frontend-landingpage/src/delegating-server.ts b/apps/agenstra/frontend-landingpage/src/delegating-server.ts index a3fe81448..6b05287de 100644 --- a/apps/agenstra/frontend-landingpage/src/delegating-server.ts +++ b/apps/agenstra/frontend-landingpage/src/delegating-server.ts @@ -3,6 +3,7 @@ import { startDelegatingServerFromImportMetaUrl } from '@forepath/shared/fronten startDelegatingServerFromImportMetaUrl(import.meta.url, { availableLocales: ['en', 'de'], defaultLocale: process.env['DEFAULT_LOCALE'] || 'en', + rootRedirectPath: '/', }); export default undefined; diff --git a/apps/decabill/frontend-billing-console/src/server.ts b/apps/decabill/frontend-billing-console/src/server.ts index 9399029fe..37c01ae1b 100644 --- a/apps/decabill/frontend-billing-console/src/server.ts +++ b/apps/decabill/frontend-billing-console/src/server.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { + buildRootRedirectLocation, createCachedStaticFile, createMemoryStaticMiddleware, createSecurityHeadersMiddleware, @@ -356,6 +357,14 @@ app.get('*', (req, res) => { } if (!getLocaleFromPath(req)) { + const requestUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + if (requestUrl.pathname === '/') { + res.redirect(301, buildRootRedirectLocation(locale, '/', requestUrl.search)); + + return; + } + res.redirect(302, `/${locale}${req.url}`); return; diff --git a/apps/decabill/frontend-landingpage/src/delegating-server.ts b/apps/decabill/frontend-landingpage/src/delegating-server.ts index a3fe81448..6b05287de 100644 --- a/apps/decabill/frontend-landingpage/src/delegating-server.ts +++ b/apps/decabill/frontend-landingpage/src/delegating-server.ts @@ -3,6 +3,7 @@ import { startDelegatingServerFromImportMetaUrl } from '@forepath/shared/fronten startDelegatingServerFromImportMetaUrl(import.meta.url, { availableLocales: ['en', 'de'], defaultLocale: process.env['DEFAULT_LOCALE'] || 'en', + rootRedirectPath: '/', }); export default undefined; diff --git a/apps/forepath/frontend-landingpage/src/delegating-server.ts b/apps/forepath/frontend-landingpage/src/delegating-server.ts index c582c2d5e..55ba6a85f 100644 --- a/apps/forepath/frontend-landingpage/src/delegating-server.ts +++ b/apps/forepath/frontend-landingpage/src/delegating-server.ts @@ -8,6 +8,7 @@ startDelegatingServerFromImportMetaUrl(import.meta.url, { availableLocales: ['en', 'de'], defaultLocale: process.env['DEFAULT_LOCALE'] || 'en', shouldBypassStatic, + rootRedirectPath: '/', }); export default undefined; diff --git a/apps/shared/frontend-docs/src/delegating-server.ts b/apps/shared/frontend-docs/src/delegating-server.ts index a3fe81448..d9d3809ca 100644 --- a/apps/shared/frontend-docs/src/delegating-server.ts +++ b/apps/shared/frontend-docs/src/delegating-server.ts @@ -3,6 +3,7 @@ import { startDelegatingServerFromImportMetaUrl } from '@forepath/shared/fronten startDelegatingServerFromImportMetaUrl(import.meta.url, { availableLocales: ['en', 'de'], defaultLocale: process.env['DEFAULT_LOCALE'] || 'en', + rootRedirectPath: '/docs', }); export default undefined; diff --git a/libs/domains/shared/frontend/util-express-server/src/delegating-server.ts b/libs/domains/shared/frontend/util-express-server/src/delegating-server.ts index 87d292cb6..af5e30321 100644 --- a/libs/domains/shared/frontend/util-express-server/src/delegating-server.ts +++ b/libs/domains/shared/frontend/util-express-server/src/delegating-server.ts @@ -1,6 +1,7 @@ // Delegating server entry — import from // `@forepath/shared/frontend/util-express-server/delegating-server` (no Angular SSR). export { + buildRootRedirectLocation, createDelegatingServer, resolveLocaleFromRequest, resolveLocalizedStaticFilePath, diff --git a/libs/domains/shared/frontend/util-express-server/src/index.ts b/libs/domains/shared/frontend/util-express-server/src/index.ts index d143cb868..2d96f9df1 100644 --- a/libs/domains/shared/frontend/util-express-server/src/index.ts +++ b/libs/domains/shared/frontend/util-express-server/src/index.ts @@ -29,6 +29,7 @@ export { } from './lib/security-headers'; export { buildSsrAllowedHosts } from './lib/ssr-allowed-hosts'; export { resolveLocalizedBrowserDistFolder, stripLocalePrefixFromPath } from './lib/localized-browser-dist'; +export { buildRootRedirectLocation } from './lib/create-delegating-server'; export { buildStaticCacheHeaders, buildStaticCacheHeadersFor304, diff --git a/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.spec.ts b/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.spec.ts index f6deda53c..7389df40b 100644 --- a/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.spec.ts +++ b/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { + buildRootRedirectLocation, createDelegatingServer, defaultLoadLocaleServerModule, resolveLocaleFromRequest, @@ -88,6 +89,11 @@ describe('create-delegating-server', () => { writeFileSync(join(serverRoot, 'browser', 'en', 'about.html'), 'about'); mkdirSync(join(serverRoot, 'browser', 'en', 'pricing'), { recursive: true }); writeFileSync(join(serverRoot, 'browser', 'en', 'pricing', 'index.html'), 'pricing'); + mkdirSync(join(serverRoot, 'browser', 'en', 'docs'), { recursive: true }); + writeFileSync(join(serverRoot, 'browser', 'en', 'docs', 'index.html'), 'en-docs'); + mkdirSync(join(serverRoot, 'browser', 'de', 'docs'), { recursive: true }); + writeFileSync(join(serverRoot, 'browser', 'de', 'index.html'), 'de-home'); + writeFileSync(join(serverRoot, 'browser', 'de', 'docs', 'index.html'), 'de-docs'); mkdirSync(join(serverRoot, 'en'), { recursive: true }); mkdirSync(join(serverRoot, 'de'), { recursive: true }); writeFileSync(join(serverRoot, 'en', 'server.mjs'), '// placeholder for existsSync\n'); @@ -127,6 +133,15 @@ describe('create-delegating-server', () => { }); }); + describe('buildRootRedirectLocation', () => { + it('normalizes landing and docs redirect targets', () => { + expect(buildRootRedirectLocation('en', '/')).toBe('/en/'); + expect(buildRootRedirectLocation('de', '/docs')).toBe('/de/docs'); + expect(buildRootRedirectLocation('en', '/docs', '?utm=x')).toBe('/en/docs?utm=x'); + expect(buildRootRedirectLocation('en', '/', '?utm=x')).toBe('/en/?utm=x'); + }); + }); + describe('defaultLoadLocaleServerModule', () => { it('dynamically imports a locale server module path', async () => { const loaded = await defaultLoadLocaleServerModule(join(serverRoot, 'en', 'server.mjs')); @@ -474,6 +489,86 @@ describe('create-delegating-server', () => { } } }); + + it('301-redirects bare / to /{locale}/docs when rootRedirectPath is /docs', async () => { + const handle = createDelegatingServer({ + serverRoot, + availableLocales: ['en', 'de'], + defaultLocale: 'en', + port: 0, + rootRedirectPath: '/docs', + loadLocaleServerModule: createLocaleLoader({ en: enHandler, de: deHandler }), + }); + + await handle.listen(); + const address = handle.server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const root = await httpGet(port, '/'); + expect(root.status).toBe(301); + expect(root.headers['location']).toBe('/en/docs'); + expect(root.body).toBe(''); + + const withQuery = await httpGet(port, '/?utm=x'); + expect(withQuery.status).toBe(301); + expect(withQuery.headers['location']).toBe('/en/docs?utm=x'); + + const deRoot = await httpGet(port, '/', { 'accept-language': 'de' }); + expect(deRoot.status).toBe(301); + expect(deRoot.headers['location']).toBe('/de/docs'); + + const docsHome = await httpGet(port, '/en/docs'); + expect(docsHome.status).toBe(200); + expect(docsHome.body).toContain('en-docs'); + + const about = await httpGet(port, '/en/about'); + expect(about.status).toBe(200); + expect(about.body).toContain('about'); + } finally { + await closeServer(handle.server); + } + }); + + it('301-redirects bare / to /{locale}/ when rootRedirectPath is /', async () => { + const handle = createDelegatingServer({ + serverRoot, + availableLocales: ['en', 'de'], + defaultLocale: 'en', + port: 0, + rootRedirectPath: '/', + loadLocaleServerModule: createLocaleLoader({ en: enHandler, de: deHandler }), + }); + + await handle.listen(); + const address = handle.server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + + try { + const root = await httpGet(port, '/'); + expect(root.status).toBe(301); + expect(root.headers['location']).toBe('/en/'); + expect(root.body).toBe(''); + + const withQuery = await httpGet(port, '/?utm=x'); + expect(withQuery.status).toBe(301); + expect(withQuery.headers['location']).toBe('/en/?utm=x'); + + const deRoot = await httpGet(port, '/', { 'accept-language': 'de' }); + expect(deRoot.status).toBe(301); + expect(deRoot.headers['location']).toBe('/de/'); + + const localeHome = await httpGet(port, '/en/'); + expect(localeHome.status).toBe(200); + expect(localeHome.body).toContain('en-home'); + + const deHome = await httpGet(port, '/de/'); + expect(deHome.status).toBe(200); + expect(deHome.body).toContain('de-home'); + } finally { + await closeServer(handle.server); + } + }); }); describe('startDelegatingServerFromImportMetaUrl', () => { diff --git a/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.ts b/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.ts index 9e9ad633f..fe388e0df 100644 --- a/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.ts +++ b/libs/domains/shared/frontend/util-express-server/src/lib/create-delegating-server.ts @@ -28,6 +28,12 @@ export interface CreateDelegatingServerOptions { defaultLocale?: string; port?: number | string; shouldBypassStatic?: (pathname: string) => boolean; + /** + * When set, `GET /` returns HTTP 301 to `/{locale}{rootRedirectPath}` instead of + * serving the locale `index.html` (docs: `'/docs'`, landings: `'/'`). + * Exact pathname `/` only; unset preserves current behavior. + */ + rootRedirectPath?: string; /** * Optional override for loading locale `server.mjs` modules (tests). * Defaults to a dynamic `import()` of the file URL. @@ -35,6 +41,19 @@ export interface CreateDelegatingServerOptions { loadLocaleServerModule?: LocaleServerModuleLoader; } +/** + * Builds `Location` for a bare-host root redirect (`/` → `/{locale}/…`). + * `rootRedirectPath: '/'` yields `/{locale}/`; `'/docs'` yields `/{locale}/docs`. + */ +export function buildRootRedirectLocation(locale: string, rootRedirectPath: string, search = ''): string { + const path = + rootRedirectPath === '/' + ? `/${locale}/` + : `/${locale}${rootRedirectPath.startsWith('/') ? rootRedirectPath : `/${rootRedirectPath}`}`; + + return `${path}${search}`; +} + export interface DelegatingServerHandle { server: Server; listen: () => Promise; @@ -197,7 +216,7 @@ export function createDelegatingServer(options: CreateDelegatingServerOptions): const availableLocales = options.availableLocales ?? ['en', 'de']; const defaultLocale = options.defaultLocale ?? 'en'; const port = options.port ?? process.env['PORT'] ?? 4000; - const { serverRoot, shouldBypassStatic } = options; + const { serverRoot, shouldBypassStatic, rootRedirectPath } = options; const loadLocaleServerModule = options.loadLocaleServerModule ?? defaultLoadLocaleServerModule; let localeServers = new Map(); @@ -212,6 +231,17 @@ export function createDelegatingServer(options: CreateDelegatingServerOptions): return; } + if (requestUrl.pathname === '/' && rootRedirectPath) { + const redirectLocale = resolveLocaleFromRequest(req, availableLocales, defaultLocale); + + res.writeHead(301, { + Location: buildRootRedirectLocation(redirectLocale, rootRedirectPath, requestUrl.search), + }); + res.end(); + + return; + } + const locale = resolveLocaleFromRequest(req, availableLocales, defaultLocale); const localeServer = localeServers.get(locale);