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

SSR with i18n with Angular 9 not working

Open joshribb opened this issue 5 years ago • 53 comments

🐞 Bug report

What modules are related to this issue?

  • [ ] aspnetcore-engine
  • [ ] builders
  • [ ] common
  • [x] express-engine
  • [ ] hapi-engine
  • [ ] module-map-ngfactory-loader

Is this a regression?

No, localize is new to Angular 9.

Description

The distFolder is hardcoded in ./server.ts. When the browser server assets are built with localize: true, the assets are placed in a subfolder with the locale name (eg: dist/{appName}/browser/{locale}/ and dist/{appName}/server/{locale}). Now the server can no longer find the correct directory for the browser assets and fails to render.

Is there any way server.ts can know location of the browser assets without hardcoding the path?

Thanks.

🔬 Minimal Reproduction

ng new ng-sample
ng add @angular/localize@next
ng add @nguniversal/express-engine@next

add localize: true to the options of the build and server architect

ng build --prod
ng run ng-sample:server:production
node dist/ng-sample/server/en-US/main.js

browser to http://localhost:4000

🔥 Exception or Error

Error: Failed to lookup view "index" in views directory "/code/ng-sample/dist/ng-sample/browser"
    at Function.render (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:1122933)
    at ServerResponse.render (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:1398756)
    at server.get (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:2259271)
    at Layer.handle [as handle_request] (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:1144375)
    at next (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:1131913)
    at Route.dispatch (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:1131942)
    at Layer.handle [as handle_request] (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:1144375)
    at /code/ng-sample/dist/ng-sample/server/en-US/main.js:1:2473361
    at param (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:2474870)
    at param (/code/ng-sample/dist/ng-sample/server/en-US/main.js:1:2475277)

🌍 Your Environment

Angular CLI: 9.0.0-rc.9
Node: 10.16.0
OS: darwin x64

Angular: 9.0.0-rc.9
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, localize, platform-browser
... platform-browser-dynamic, platform-server, router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.900.0-rc.9
@angular-devkit/build-angular     0.900.0-rc.9
@angular-devkit/build-optimizer   0.900.0-rc.9
@angular-devkit/build-webpack     0.900.0-rc.9
@angular-devkit/core              9.0.0-rc.9
@angular-devkit/schematics        9.0.0-rc.9
@ngtools/webpack                  9.0.0-rc.9
@nguniversal/builders             9.0.0-rc.0
@nguniversal/common               9.0.0-rc.0
@nguniversal/express-engine       9.0.0-rc.0
@schematics/angular               9.0.0-rc.9
@schematics/update                0.900.0-rc.9
rxjs                              6.5.4
typescript                        3.6.4
webpack                           4.41.2

joshribb avatar Jan 17 '20 19:01 joshribb

I updated my answer on https://github.com/angular/universal/issues/1497

keserwan avatar Feb 11 '20 07:02 keserwan

I made it work with 2 node apps working on the server. If anybody can come up with 1 node app serving all locales that would be perfect as we were doing it with webpack.server.config previously.

keserwan avatar Feb 11 '20 08:02 keserwan

I am in the same boat: Previously I could import multiple bundles and load them dynamically based on the URL that was requested. Now I need to run one server for each language, this is quite tedious.

MarcusRiemer avatar Feb 24 '20 09:02 MarcusRiemer

Same here. I adopted same setup for my production site as mentioned by @keserwan in angular/universal#1497, which is now broken.

Besides, look like this block of code is wrong. "req.baseUrl" causes app routing on server side to fail:

server.get('*', (req, res) => {
	res.render(indexHtml, {req, providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl}]});
\});

I changed it as follows, and the routing works again:

const baseHref = '/en/';
server.get(baseHref + '*', (req, res) => {
	res.render(indexHtml, {req, providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
});

ymlsam avatar Mar 14 '20 20:03 ymlsam

Any update about this ?

squelix avatar Mar 19 '20 08:03 squelix

Hi, Is there any progress about this issue?

ftanrisevdi avatar Apr 01 '20 08:04 ftanrisevdi

I add args to my server.ts : server.ts:

var args = process.argv.splice(process.execArgv.length + 2);
var local = args[0];
const distFolder = join(__dirname, '../../browser', local);

and run it with:

    "serve:ssr:pl": "node dist/app/server/pl/main.js pl",

but I have a problem with build configuration in angular.json. What is a proper way to build ssr with many configurations at once - production and some locale ?

piotrbrzuska avatar Apr 02 '20 12:04 piotrbrzuska

Ok, I spend about a one work day, but I'm do it.

a idea: to have files like ~/dist/app-name/server/{locale}/main.js and one main ~/server.js, which start all locales servers as modules:

var express = require('express')
const server = express();

var locals = ["en", "pl", "fr", "de"]; 
for (let idx = 0; idx < locals.length; idx++) {
    const local = locals[idx];
    var localModule = require(`./dist/app-name/server/${local}/main.js`);
    server.use('/'+local, localModule.app(local));
    idx == 0 && server.use('/', localModule.app(local)); // use first locale as main locale
}
const port = process.env.PORT || 4000;
server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
});

other things i must change is pass Locale_ID to APP_BASE_HREF in my (Browser) AppModule.

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
 // ...
  ],
  providers: [
// ....
    { provide: APP_BASE_HREF, useFactory: (locale: string) => locale, deps: [LOCALE_ID] },
// ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule { 

piotrbrzuska avatar Apr 03 '20 13:04 piotrbrzuska

@piotrbrzuska

For server localize build, you can use parameter localize on server task on angular.json:

"server": {
    "builder": "@angular-devkit/build-angular:server",
     "options": {
         ...
            "localize": ["en", "ru"]
         ...  
     }
}

Fafnur avatar Apr 04 '20 06:04 Fafnur

Ok, I spend about a one work day, but I'm do it.

a idea: to have files like ~/dist/app-name/server/{locale}/main.js and one main ~/server.js, which start all locales servers as modules:

var express = require('express')
const server = express();

var locals = ["en", "pl", "fr", "de"]; 
for (let idx = 0; idx < locals.length; idx++) {
    const local = locals[idx];
    var localModule = require(`./dist/app-name/server/${local}/main.js`);
    server.use('/'+local, localModule.app(local));
    idx == 0 && server.use('/', localModule.app(local)); // use first locale as main locale
}
const port = process.env.PORT || 4000;
server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
});

other things i must change is pass Locale_ID to APP_BASE_HREF in my (Browser) AppModule.

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
 // ...
  ],
  providers: [
// ....
    { provide: APP_BASE_HREF, useFactory: (locale: string) => locale, deps: [LOCALE_ID] },
// ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule { 

Can i have your full config please ?

squelix avatar Apr 06 '20 12:04 squelix

any solution to run application (with multiple language) on single express port?????

baj9032 avatar May 11 '20 09:05 baj9032

@piotrbrzuska solution worked for me.

Basically, I did the following:

server.ts:

export function app(locale) {
    const server = express();

    server.engine(
        'html',
        ngExpressEngine({
            bootstrap: AppServerModule,
        })
    );

    const distPath = join(process.cwd(), `dist/my-app/browser/${locale}`);

    //server.set('views', distPath);
    //server.set('view engine', 'html');

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

    server.get('*', (req, res) => {
        res.render(join(distPath, 'index.html'), {
            req,
            providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
        });
    });

    return server;
}

export * from './src/main.server';

Then I created a separate server.run.js with this:


function app() {
    const server = express();

    ['ca', 'en', 'en-gb', 'es'].forEach((locale) => {
        const appServerModule = require(path.join(__dirname, 'dist', 'my-app', 'server', locale, 'main.js'));
        server.use(`/${locale}`, appServerModule.app(locale));
    });

    return server;
}

function run() {
    app().listen(4200, () => {
        console.log(`Node Express server listening on http://localhost:4200`);
    });
}

run();


marcmarcet avatar May 13 '20 17:05 marcmarcet

@piotrbrzuska solution worked for me.

Basically, I did the following:

server.ts:

export function app(locale) {
    const server = express();

    server.engine(
        'html',
        ngExpressEngine({
            bootstrap: AppServerModule,
        })
    );

    const distPath = join(process.cwd(), `dist/my-app/browser/${locale}`);

    //server.set('views', distPath);
    //server.set('view engine', 'html');

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

    server.get('*', (req, res) => {
        res.render(join(distPath, 'index.html'), {
            req,
            providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
        });
    });

    return server;
}

export * from './src/main.server';

Then I created a separate server.run.js with this:


function app() {
    const server = express();

    ['ca', 'en', 'en-gb', 'es'].forEach((locale) => {
        const appServerModule = require(path.join(__dirname, 'dist', 'my-app', 'server', locale, 'main.js'));
        server.use(`/${locale}`, appServerModule.app(locale));
    });

    return server;
}

function run() {
    app().listen(4200, () => {
        console.log(`Node Express server listening on http://localhost:4200`);
    });
}

run();

wow this is awesome!!! perfect solution! thanks!

p3x-robot avatar May 14 '20 13:05 p3x-robot

@marcmarcet-codinghumans thanks for the solution, but how you build your server.run.ts?

If I build it the same way as the angular server webpack warns me: Critical dependency: the request of a dependency is an expression.

I think it's because of using dynamic require.

panki avatar May 20 '20 23:05 panki

@marcmarcet-codinghumans thanks for the solution, but how you build your server.run.ts?

If I build it the same way as the angular server webpack warns me: Critical dependency: the request of a dependency is an expression.

I think it's because of using dynamic require.

nope, for me it works, and i am using multiple languages with 1 express app.

p3x-robot avatar May 21 '20 05:05 p3x-robot

@marcmarcet-codinghumans thanks for the solution, but how you build your server.run.ts? If I build it the same way as the angular server webpack warns me: Critical dependency: the request of a dependency is an expression. I think it's because of using dynamic require.

nope, for me it works, and i am using multiple languages with 1 express app.

do you use custom webpack config to build server.run.ts?

panki avatar May 21 '20 17:05 panki

@marcmarcet-codinghumans thanks for the solution, but how you build your server.run.ts? If I build it the same way as the angular server webpack warns me: Critical dependency: the request of a dependency is an expression. I think it's because of using dynamic require.

nope, for me it works, and i am using multiple languages with 1 express app.

do you use custom webpack config to build server.run.ts?

server.ts

/***************************************************************************************************
 * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
 */
import '@angular/localize/init';
import 'zone.js/dist/zone-node';

import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { join } from 'path';

import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
//import { existsSync } from 'fs';

// The Express app is exported so that it can be used by serverless Functions.
export function app(locale) {
  const server = express();

//  const distFolder = join(process.cwd(), 'dist/backwash-ai/browser');

  //const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';

  // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
  server.engine('html', ngExpressEngine({
    bootstrap: AppServerModule,
  }));

  const distPath = join(process.cwd(), `dist/backwash-ai/browser/${locale}`);

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

  // Example Express Rest API endpoints
  // server.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('*.*', express.static(distPath, {
    maxAge: '1y'
  }));

  // All regular routes use the Universal engine
  server.get('*', (req, res) => {
    res.render(join(distPath, 'index.html'),  { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
  });

  return server;
}

/*
function run() {
  const port = process.env.PORT || 1978;

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

// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = mainModule && mainModule.filename || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
  run();
}
 */

export * from './src/main.server';

server.run.js

const path = require('path')
const apiService = require('./api/src/service/boot')

function runApps(appWithOptions) {

  const server = appWithOptions.express.app

  const locales = appWithOptions.config.locales

  locales.forEach((locale) => {
    const appServerModule = require(path.join(__dirname, 'dist', 'backwash-ai', 'server', locale, 'main.js'));
    server.use(`/${locale}`, appServerModule.app(locale));
  });

}

function run() {
  process.env.NODE_ENV = process.env.NODE_ENV || 'development'
  const appWithOptions = apiService({
    express: {
      cors: false
    }
  })

  const app = appWithOptions.express.app

  if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'ssr') {
    runApps(appWithOptions)

    const findOutLocale = (cookieLocale) => {
      if (appWithOptions.config.locales.includes(cookieLocale)) {
        return cookieLocale
      }
      return appWithOptions.config.defaultLocale
    }

    app.get('/', function(req, res) {
      res.redirect(`/${findOutLocale(req.cookies['bw-lang'])}`);
    });

    app.get('*', function(req, res) {
      res.redirect(`/${findOutLocale(req.cookies['bw-lang'])}` + req.url);
    });
  }

  const port = process.env.PORT || appWithOptions.config.port;

  appWithOptions.express.app.listen(port, () => {
    console.log(`backwash-ai listening on http://localhost:${port} on ${process.env.NODE_ENV}`);
  });
}

run();

i have a hack that is described here: https://github.com/angular/universal/issues/1689

angular.json

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "backwash-ai": {
      "i18n": {
        "sourceLocale": "en",
        "locales": {
          "hu": "messages.hu.xlf"
        }
      },
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": {
          "skipTests": true,
          "style": "scss"
        }
      },
      "root": "",
      "sourceRoot": "src",
      "prefix": "bw",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "localize": ["en", "hu"],
            "outputPath": "dist/backwash-ai/browser",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.scss"
            ],
            "scripts": []
          },
          "configurations": {
            "en": {
              "localize": ["en"],
              "baseHref": "/en/"
            },
            "production": {
              "i18nMissingTranslation": "error",
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "optimization": true,
              "outputHashing": "all",
              "sourceMap": false,
              "extractCss": true,
              "namedChunks": false,
              "extractLicenses": true,
              "vendorChunk": false,
              "buildOptimizer": true,
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "2mb",
                  "maximumError": "5mb"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "6kb",
                  "maximumError": "10kb"
                }
              ]
            }
          }
        },
        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "options": {
            "browserTarget": "backwash-ai:build"
          },
          "configurations": {
            "production": {
              "browserTarget": "backwash-ai:build:production"
            },
            "en": {
              "browserTarget": "backwash-ai:build:en"
            }
          }
        },
        "extract-i18n": {
          "builder": "@angular-devkit/build-angular:extract-i18n",
          "options": {
            "browserTarget": "backwash-ai:build"
          }
        },
        "test": {
          "builder": "@angular-devkit/build-angular:karma",
          "options": {
            "main": "src/test.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.spec.json",
            "karmaConfig": "karma.conf.js",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.scss"
            ],
            "scripts": []
          }
        },
        "lint": {
          "builder": "@angular-devkit/build-angular:tslint",
          "options": {
            "tsConfig": [
              "tsconfig.app.json",
              "tsconfig.spec.json",
              "e2e/tsconfig.json"
            ],
            "exclude": [
              "**/node_modules/**"
            ]
          }
        },
        "e2e": {
          "builder": "@angular-devkit/build-angular:protractor",
          "options": {
            "protractorConfig": "e2e/protractor.conf.js",
            "devServerTarget": "backwash-ai:serve"
          },
          "configurations": {
            "production": {
              "devServerTarget": "backwash-ai:serve:production"
            }
          }
        },
        "server": {
          "builder": "@angular-devkit/build-angular:server",
          "options": {
            "outputPath": "dist/backwash-ai/server",
            "main": "server.ts",
            "localize": ["en", "hu"],
            "tsConfig": "tsconfig.server.json"
          },
          "configurations": {
            "production": {
              "outputHashing": "media",
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "sourceMap": false,
              "optimization": true
            }
          }
        },
        "serve-ssr": {
          "builder": "@nguniversal/builders:ssr-dev-server",
          "options": {
            "browserTarget": "backwash-ai:build",
            "serverTarget": "backwash-ai:server"
          },
          "configurations": {
            "production": {
              "browserTarget": "backwash-ai:build:production",
              "serverTarget": "backwash-ai:server:production"
            }
          }
        },
        "prerender": {
          "builder": "@nguniversal/builders:prerender",
          "options": {
            "browserTarget": "backwash-ai:build:production",
            "serverTarget": "backwash-ai:server:production",
            "routes": [
              "/"
            ]
          },
          "configurations": {
            "production": {}
          }
        }
      }
    }},
  "defaultProject": "backwash-ai"
}

p3x-robot avatar May 21 '20 17:05 p3x-robot

@p3x-robot could you also show your tsconfig.server.json?

panki avatar May 21 '20 18:05 panki

@p3x-robot I don't see a target to build server.run.ts in your angular.json', but looks like you somehow managed to build both server.ts and server.run.ts by running ng run backwash-ai:server:production, did I miss something?

panki avatar May 21 '20 18:05 panki

tsconfig.server.json

{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app-server",
    "module": "commonjs",
    "types": [
      "node"
    ]
  },
  "files": [
    "src/main.server.ts",
    "server.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}

as i said, it is a hack an is written here: https://github.com/angular/universal/issues/1689

p3x-robot avatar May 22 '20 04:05 p3x-robot

Maybe this can help for inspiration as well:

https://medium.com/@marcozuccaroli/a-multilanguage-application-with-angular-universal-6e5fe4c2d81c

spock123 avatar Jun 10 '20 12:06 spock123

Any update of this, at incoming Angular 10 ?

adamgasiorek avatar Jun 13 '20 17:06 adamgasiorek

I am sharing ready working solution for Angular 10 on one port based on your answers 🚀🚀

Repositorium

Angular documentation is so deprecated, maybe this gonna helps someone ;)

adamgasiorek avatar Jul 06 '20 17:07 adamgasiorek

I tried to apply @marcmarcet-codinghumans solution for my project but I keep getting a server timeout. I am hosting my project using Firebase. Does anybody have successfully served an angular universal app with i18n and firebase/cloud function ? Here's my question on stackoverflow for the details.

kana7 avatar Aug 24 '20 14:08 kana7

I tried to apply @marcmarcet-codinghumans solution for my project but I keep getting a server timeout. I am hosting my project using Firebase. Does anybody have successfully served an angular universal app with i18n and firebase/cloud function ? Here's my question on stackoverflow for the details.

You're using US-1 region, right? Cloud Functions only work in that region afaik

spock123 avatar Aug 24 '20 14:08 spock123

You're using US-1 region, right? Cloud Functions only work in that region afaik

Yes, I do. The cloud function get executed, but it doesn't render anything when I do:

server.get('*', (req, res) => {
   // this log shows up in my firebase console
   console.log(`serving request, with locale ${locale}, base url: ${req.baseUrl}, accept-language: ${req.headers["accept-language"]}`);
    res.render('index.html', {
      req,
      providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
    });
  });

kana7 avatar Aug 24 '20 15:08 kana7

It turns out that the problem comes from angularFire (firebase/firestore). Any data query using a rxjs pipe with take(1) in the application, cause angular universal to get stuck in a infinite loop until the server timeout.. https://github.com/angular/angularfire/issues/2420

kana7 avatar Aug 27 '20 13:08 kana7

why it is always a nightmare deploying SSR with i18n to firebase?

ranibb avatar Sep 16 '20 19:09 ranibb

Here is the entire commit (minus my app very specific changes) that I used to add SSR on an Angular 10 app that already used i18n: https://gist.github.com/PowerKiKi/b8ecd4bdfb3f4d694a1370e3c57a5062

It is based on the server.run.js solution. But it automatically gets locales from angular.json (so no duplicated config). And it automatically use the proxy config that you might need for your local API.

server.ts still has its run() function in order to run yarn dev-ssr, although the app still fails because of incorrect baseHref. And it has a full configuration for pm2 where you can see that server.run.js is the main entry point (and not server.ts anymore).

And to be extra complete here is the relevant nginx configuration to proxy bots, but no humans, to the SSR.

PowerKiKi avatar Oct 16 '20 06:10 PowerKiKi

I followed the guide in this blog post, helped me alot. https://medium.com/@pierre.machaux/angular-universal-and-i18n-working-together-8828423e8a68

boban100janovski avatar Nov 24 '20 16:11 boban100janovski