angular-cli icon indicating copy to clipboard operation
angular-cli copied to clipboard

Support for Multi-Domain Locale Detection in Angular SSR

Open alan-agius4 opened this issue 1 year ago • 5 comments

Description

Currently, Angular SSR does not provide built-in functionality to detect the domain in a multi-domain setup and serve the appropriate localized version of the application. This feature would allow Angular SSR to dynamically render pages with the correct locale based on the domain.

Use Case:

  • Applications serving different localized versions (e.g., example.com, fr.example.com, example.de) need SSR to serve the appropriate locale.
  • Example:
    • example.com → English content
    • fr.example.com → French content
    • example.de → German content

Describe the solution you'd like

Allow SSR to use the detected domain to select the corresponding locale configuration. Which simplifies multi-language deployment without relying on path-based locales (e.g., /en, /fr).

Describe alternatives you've considered

No response

alan-agius4 avatar Dec 11 '24 13:12 alan-agius4

Just my two cents: I had to do that for a backend (not using Angular SSR), and I found it rather elegant to do it by relying on the Accept-Language header.

The reverse-proxy in front of the backend would be configured to set the appropriate Accept-Language header value based on the domain. This limits the configuration that needs to be done on the backend side.

And relying on the Accept-Language header could also be a good thing for simpler deployments where there is a single URL to serve the app, and where the Accept-Language header coming from the browser is used to decide which locale to use.

jnizet avatar Dec 11 '24 20:12 jnizet

@jnizet but how to remove locale from path? AppEngine still redirects to /en-GB

I think the only current default pattern of locale detection must be a part of framework, but framework may provide interface to override ServerApp resolving and request handling (e.g. redirects) strategy

autowp avatar Nov 07 '25 22:11 autowp

@autowp that could be handled by a reverse proxy as well.

Example NGINX has proxy_redirect.

Note: I haven’t personally tried this to validate that this approach would for once the CSR app takes over.

alan-agius4 avatar Nov 07 '25 22:11 alan-agius4

@autowp that could be handled by a reverse proxy as well.

Example NGINX has proxy_redirect.

Note: I haven’t personally tried this to validate that this approach would for once the CSR app takes over.

Was tried to workaround with intermediate request processing using vhost: add /{locale}/... prefix and Accept-Language header

for (const lang of environment.languages) { // list of hostname/locale pairs
  const vhostApp = express();
  const browserDistFolder = join(import.meta.dirname, '../browser/' + lang.locale); // static path affected

  vhostApp.use(
    express.static(browserDistFolder, {
      maxAge: '1y',
      index: false,
      redirect: false,
    }),
  );

  vhostApp.use((req, res, next) => {
    // angular app path affected
    // prevent trailing slash at locale root: it follows to unexpected redirect
    req.url = req.originalUrl = '/' + lang.locale + (req.originalUrl === '/' ? '' : req.originalUrl);
    req.headers['accept-language'] = lang.locale;

    angularApp
      .handle(req)
      .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
      .catch(next);
  });

  app.use(vhost(lang.hostname, vhostApp));
}

That helps to select a valid locale version by hostname, but app itself fails because of routing (req.url starts with extra segment - locale). Omitting locale segment follows to redirect via redirectBasedOnAcceptLanguage

autowp avatar Nov 09 '25 11:11 autowp

Finally i found the dirty but working solution.

The key point is to replace AngularAppEngine::handle() and AngularAppEngine::getAngularServerAppForRequest() with my own resolving logics.

// server.ts
import {
  AngularNodeAppEngine,
  createNodeRequestHandler,
  isMainModule,
  writeResponseToNodeResponse,
} from '@angular/ssr/node';
import {environment} from '@environment/environment';
import express from 'express';
import {join} from 'node:path';
import vhost from 'vhost';

const app = express();
const angularApp = new AngularNodeAppEngine();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getAngularServerAppForRequest(this: any, request: Request): Promise<unknown> {
  const potentialLocale = request.headers.get('accept-language');

  const entryPoint = await (this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports(''));
  if (!entryPoint) {
    return null;
  }

  return entryPoint.ɵgetOrCreateAngularServerApp({
    allowStaticRouteRender: this.constructor.ɵallowStaticRouteRender,
    hooks: this.constructor.ɵhooks,
  });
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handle(this: any, request: unknown, requestContext?: unknown): Promise<null | Response> {
  const serverApp = await this.getAngularServerAppForRequest(request);

  if (serverApp) {
    return serverApp.handle(request, requestContext);
  }

  return null;
}

// @ts-expect-error: TS2341: Property angularAppEngine is private and only accessible within class AngularNodeAppEngine
const appEngine = angularApp.angularAppEngine;
appEngine.handle = handle.bind(appEngine);
appEngine.getAngularServerAppForRequest = getAngularServerAppForRequest.bind(appEngine);

for (const lang of environment.languages) { // list of hostname/locale pairs
  const vhostApp = express();
  const browserDistFolder = join(import.meta.dirname, '../browser/' + lang.locale);

  /**
   * Serve static files from /browser
   */
  vhostApp.use(
    express.static(browserDistFolder, {
      maxAge: '1y',
      index: false,
      redirect: false,
    }),
  );

  /**
   * Handle all other requests by rendering the Angular application.
   */
  vhostApp.use((req, res, next) => {
    req.headers['accept-language'] = lang.locale;

    angularApp
      .handle(req)
      .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
      .catch(next);
  });

  app.use(vhost(lang.hostname, vhostApp));
}

/**
 * Start the server if this module is the main entry point, or it is ran via PM2.
 * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
 */
if (isMainModule(import.meta.url) || process.env['pm_id']) {
  const port = process.env['PORT'] || 4000;
  app.listen(port, (error) => {
    if (error) {
      throw error;
    }

    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

/**
 * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
 */
export const reqHandler = createNodeRequestHandler(app);

autowp avatar Nov 16 '25 09:11 autowp