НейроАгент

Руководство по локализации Angular SSR с поддоменами: исправление редиректов и битых URL

Полное руководство по настройке Angular SSR с разрешением локали на основе поддоменов. Узнайте, как предотвратить редиректы, исправить битые URL-адреса ресурсов и реализовать корректное определение языка на стороне сервера для многоязычных Angular-приложений.

Как настроить Angular SSR с разрешением локали на основе поддоменов для предотвращения перенаправлений и сломанных URL-адресов

Я настраиваю Angular SSR с локализацией, где разные языковые версии развертываются как поддомены:

Моя конфигурация angular.json:

"i18n": {
  "sourceLocale": {
    "code": "en-GB",
    "baseHref": "/"
  },
  "locales": {
    "uk": {
      "translation": "src/locale/messages.uk-UA.xlf",
      "baseHref": "/"
    },
    "fr": {
      "translation": "src/locale/messages.fr-FR.xlf",
      "baseHref": "/"
    },

Эта настройка работает нормально для не-SSR версий, где я использую папки dist/browser/* как корень для виртуальных серверов nginx. Однако при использовании SSR приложение неожиданно перенаправляется на /en-GB/ (на основе моего заголовка Accept-Language) и выдает ошибки из-за сломанных URL-адресов из-за baseHref=“/”.

Фактические пути к скриптам выглядят так:

<script src="main-CJQXROTY.js" type="module">

Но они должны быть:

<script src="en-GB/main-CJQXROTY.js" type="module">

Я хочу, чтобы версия en-GB открывалась напрямую на / с работающими href для скриптов и стилей. Есть ли способ исправить или обойти это? Могу ли я переопределить логику перенаправления для разрешения языка из HTTP-запроса через поддомен? Или мне следует создавать отдельные Node SSR приложения для каждого языка и размещать их независимо? Я могу заменить Accept-Language на основе nginx в зависимости от имени хоста для выбора правильного языка, но я не могу решить проблему с baseHref и перенаправлениями.

НейроАгент

Основная проблема, с которой вы столкнулись, связана с логикой перенаправления Angular SSR и конфликтами конфигурации baseHref с локализацией на основе субдоменов. Для решения этой проблемы необходимо реализовать правильный механизм определения языка на стороне сервера, который переопределяет поведение baseHref по умолчанию и предотвращает нежелательные перенаправления.


Содержание


Понимание проблемы

Ваша текущая конфигурация создает фундаментальный конфликт между логикой перенаправления Angular SSR и вашей архитектурой на основе субдоменов. Настройка baseHref="/" сообщает Angular, что все ресурсы должны загружаться из корня, но система SSR пытается определить язык из заголовков и применять соответствующие перенаправления baseHref.

Когда Angular SSR получает запрос к en.example.org, он автоматически:

  1. Проверяет заголовок Accept-Language
  2. Сравнивает его с вашим исходным языком (en-GB)
  3. Перенаправляет на /en-GB/ при несоответствии
  4. Генерирует пути к ресурсам с использованием настроенного baseHref

Это создает поврежденный шаблон URL, с которым вы столкнулись, поскольку сервер ожидает ресурсы по адресу main-CJQXROTY.js, но маршрутизация на стороне клиента ожидает их по адресу en-GB/main-CJQXROTY.js.

Уязвимость безопасности, упомянутая в результатах исследований, показывает, что SSR может быть уязвим для манипуляций URL, что еще больше усложняет поведение перенаправления, которое вы хотите предотвратить.

Правильная конфигурация субдоменов

Чтобы исправить это, вам нужно изменить ваш angular.json для правильной обработки маршрутизации на основе субдоменов:

json
"i18n": {
  "sourceLocale": {
    "code": "en-GB",
    "baseHref": "/"
  },
  "locales": {
    "uk": {
      "translation": "src/locale/messages.uk-UA.xlf",
      "baseHref": "/uk/"
    },
    "fr": {
      "translation": "src/locale/messages.fr-FR.xlf", 
      "baseHref": "/fr/"
    }
  }
}

Однако этого будет недостаточно для решения проблемы перенаправления SSR. Вам потребуется реализовать промежуточное ПО на стороне сервера для:

  1. Извлечения языка из субдомена
  2. Переопределения поведения перенаправления SSR по умолчанию
  3. Обеспечения правильной генерации путей к ресурсам

Определение языка на стороне сервера

Ключевым решением является реализация определения языка на стороне сервера, который имеет приоритет над определением на основе заголовков на стороне клиента. Вот как подойти к этому:

1. Изменение Server.ts

В вашей конфигурации сервера SSR добавьте промежуточное ПО для определения языка из субдомена:

typescript
import { Request, Response, NextFunction } from 'express';

export function subdomainLanguageDetection(req: Request, res: Response, next: NextFunction) {
  const host = req.headers.host;
  const subdomain = host?.split('.')[0];
  
  // Сопоставление субдоменов с кодами языков
  const subdomainToLocale: Record<string, string> = {
    'en': 'en-GB',
    'uk': 'uk-UA', 
    'fr': 'fr-FR'
  };
  
  // Установка языка запроса на основе субдомена
  if (subdomain && subdomainToLocale[subdomain]) {
    req.headers['x-locale'] = subdomainToLocale[subdomain];
    // Переопределение заголовка Accept-Language для SSR
    req.headers['accept-language'] = subdomainToLocale[subdomain];
  }
  
  next();
}

// В основной настройке сервера
app.use(subdomainLanguageDetection);

2. Создание пользовательского маршрута сервера SSR

Переопределите обработку маршрута SSR по умолчанию для предотвращения перенаправлений:

typescript
import { ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: '**',
    renderMode: 'Server',
    getPrerenderParams: () => {
      // Предотвращение автоматических перенаправлений путем возврата пустых параметров
      return [];
    }
  }
];

Стратегии предотвращения перенаправлений

Чтобы полностью предотвратить перенаправления при сохранении правильной локализации:

1. Конфигурация Nginx

Настройте nginx для обработки маршрутизации субдоменов и переопределения заголовков до достижения Angular:

nginx
server {
    listen 80;
    server_name en.example.org;
    
    # Переопределение Accept-Language на основе субдомена
    set $locale "en-GB";
    
    location / {
        proxy_set_header X-Locale "en-GB";
        proxy_set_header Accept-Language "en-GB";
        proxy_pass http://localhost:4000;
    }
}

server {
    listen 80;
    server_name uk.example.org;
    
    set $locale "uk-UA";
    
    location / {
        proxy_set_header X-Locale "uk-UA";
        proxy_set_header Accept-Language "uk-UA";
        proxy_pass http://localhost:4000;
    }
}

server {
    listen 80;
    server_name fr.example.org;
    
    set $locale "fr-FR";
    
    location / {
        proxy_set_header X-Locale "fr-FR";
        proxy_set_header Accept-Language "fr-FR";
        proxy_pass http://localhost:4000;
    }
}

2. Пользовательское промежуточное ПО SSR

Создайте промежуточное ПО, которое перехватывает и нормализует запрос перед обработкой Angular SSR:

typescript
import { Injectable, Inject } from '@angular/core';
import { REQUEST } from '@nguniversal/express-engine/tokens';

@Injectable()
export class SubdomainLocaleService {
  constructor(@Inject(REQUEST) private request: any) {}

  getLocale(): string {
    // Сначала проверка пользовательского заголовка
    if (this.request.headers['x-locale']) {
      return this.request.headers['x-locale'];
    }
    
    // Откат к определению из субдомена
    const host = this.request.headers.host;
    const subdomain = host?.split('.')[0];
    
    const subdomainToLocale: Record<string, string> = {
      'en': 'en-GB',
      'uk': 'uk-UA',
      'fr': 'fr-FR'
    };
    
    return subdomainToLocale[subdomain] || 'en-GB';
  }
}

Решения для сборки и развертывания

Вариант 1: Единое приложение SSR с определением во время выполнения

Разверните единое приложение SSR, которое определяет язык во время выполнения:

bash
# Сборка для производства с SSR
ng build --configuration production --localize

# Запуск SSR
ng serve --configuration production --disable-host-check

Преимущества:

  • Единая кодовая база для поддержки
  • Общие ресурсы сервера
  • Упрощенное управление развертыванием

Недостатки:

  • Накладные расходы на определение языка во время выполнения
  • Потенциальная сложность кэширования

Вариант 2: Отдельные приложения SSR для каждого языка

Собирайте и развертывайте отдельные экземпляры SSR для каждого языка:

bash
# Сборка английской версии
ng build --configuration production --localize --source-map=false --base-href="/"

# Сборка украинской версии  
ng build --configuration production --localize --source-map=false --base-href="/uk/"

# Сборка французской версии
ng build --configuration production --localize --source-map=false --base-href="/fr/"

Преимущества:

  • Оптимальная производительность для каждого языка
  • Упрощенное кэширование
  • Независимое масштабирование развертывания

Недостатки:

  • Несколько кодовых баз для поддержки
  • Более высокая сложность развертывания

Альтернативные подходы

1. Локализация на основе путей с прокси-сервером субдоменов

Сохраните внутреннюю локализацию на основе путей, но используйте субдомены в качестве прокси:

nginx
server {
    listen 80;
    server_name en.example.org;
    
    location / {
        proxy_pass http://localhost:4000/en-GB/;
    }
}

server {
    listen 80;
    server_name uk.example.org;
    
    location / {
        proxy_pass http://localhost:4000/uk-UA/;
    }
}

2. Пользовательское переопределение BaseHref

Создайте пользовательскую реализацию, которая переопределяет baseHref на основе субдомена:

typescript
import { APP_BASE_HREF } from '@angular/common';
import { Inject, Injectable } from '@angular/core';

@Injectable()
export class CustomBaseHrefService {
  constructor(@Inject(APP_BASE_HREF) private baseHref: string) {}
  
  getBaseHref(): string {
    const host = window.location.host;
    const subdomain = host?.split('.')[0];
    
    const subdomainToBaseHref: Record<string, string> = {
      'en': '/',
      'uk': '/uk/',
      'fr': '/fr/'
    };
    
    return subdomainToBaseHref[subdomain] || '/';
  }
}

Пример реализации

Вот полный рабочий пример реализации:

1. Обновление angular.json

json
{
  "projects": {
    "your-app": {
      "i18n": {
        "sourceLocale": {
          "code": "en-GB",
          "baseHref": "/"
        },
        "locales": {
          "uk": {
            "translation": "src/locale/messages.uk-UA.xlf",
            "baseHref": "/uk/"
          },
          "fr": {
            "translation": "src/locale/messages.fr-FR.xlf",
            "baseHref": "/fr/"
          }
        }
      }
    }
  }
}

2. Создание промежуточного ПО SSR

typescript
// src/server/middleware/subdomain-locale.ts
import { Request, Response, NextFunction } from 'express';

export function subdomainLocaleMiddleware(req: Request, res: Response, next: NextFunction) {
  const host = req.headers.host;
  const subdomain = host?.split('.')[0];
  
  const localeMap: Record<string, string> = {
    'en': 'en-GB',
    'uk': 'uk-UA',
    'fr': 'fr-FR'
  };
  
  const locale = localeMap[subdomain] || 'en-GB';
  
  // Установка пользовательских заголовков для SSR
  req.headers['x-app-locale'] = locale;
  req.headers['accept-language'] = locale;
  
  next();
}

3. Конфигурация Server.ts

typescript
// src/server.ts
import 'zone.js/node';
import { enableProdMode } from '@angular/core';
import { provideServerRendering } from '@angular/ssr';
import { ngExpressEngine } from '@nguniversal/express-engine';
import { existsSync } from 'fs';
import { join } from 'path';

// Импорт промежуточного ПО
import { subdomainLocaleMiddleware } from './server/middleware/subdomain-locale';

enableProdMode();

const express = require('express');
const app = express();
const port = 4000;

const distFolder = join(process.cwd(), 'dist/your-app/browser');
const indexHtml = existsSync(join(distFolder, 'index.html')) 
  ? `/${join(distFolder, 'index.html')}` 
  : './src/index.html';

app.engine('html', ngExpressEngine({
  bootstrap: () => import('./src/main.server').then(m => m.app),
  providers: [provideServerRendering()]
}));

app.set('view engine', 'html');
app.set('views', distFolder);

// Применение промежуточного ПО перед SSR
app.use(subdomainLocaleMiddleware);

app.get('*.*', express.static(distFolder, {
  maxAge: '1y'
}));

app.get('*', (req, res) => {
  res.render(indexHtml, {
    req,
    providers: [
      { provide: 'REQUEST', useValue: req }
    ]
  });
});

app.listen(port, () => {
  console.log(`Node server listening on http://localhost:${port}`);
});

Эта реализация будет:

  1. Определять язык из субдомена на стороне сервера
  2. Переопределять заголовки Accept-Language для предотвращения перенаправлений
  3. Поддерживать правильные пути к ресурсам на основе настроенного baseHref
  4. Бесшовно работать с вашей архитектурой субдоменов

Заключение

  1. Основная проблема связана с автоматической логикой перенаправления Angular SSR, конфликтующей с вашей настройкой локализации на основе субдоменов.

  2. Определение на стороне сервера является обязательным - переопределите заголовок Accept-Language на основе субдомена перед обработкой запроса Angular.

  3. Реализация промежуточного ПО может предотвратить нежелательные перенаправления при сохранении правильной генерации путей к ресурсам.

  4. Конфигурация Nginx предоставляет дополнительный уровень контроля для манипуляции заголовками и маршрутизацией.

  5. Учитывайте стратегию развертывания - единое приложение SSR с определением во время выполнения проще, в то время как отдельные экземпляры предлагают лучшую производительность для каждого языка.

Ключевое понимание заключается в том, что Angular SSR требует явного указания, какой язык использовать при обслуживании с субдоменов, поскольку по умолчанию используется определение на основе заголовков, что вызывает поведение перенаправления, которое вы хотите избежать.


Источники

  1. Пример многоязычного приложения Angular с i18n и SSR (размещено на Firebase App Hosting) - Lukas Bühler

  2. Fix(@angular/ssr): prevent malicious URL from overriding host - Angular CLI

  3. CVE-2025-62427: CWE-918: Server-Side Request Forgery in angular angular-cli - OffSeq Threat Intelligence

  4. Understanding SSR in Angular - Sorus Gentrification

  5. Angular SSR: ‘getPrerenderParams’ is missing ERROR - Piyali Das