nestjs / ng-universal

Angular Universal module for Nest framework (node.js) 🌷
https://nestjs.com
MIT License
441 stars 68 forks source link

@nestjs/ng-universal not working with Angular 13 - Error [ERR_REQUIRE_ESM]: require() of ES Module #833

Open LeParadoxHD opened 2 years ago

LeParadoxHD commented 2 years ago

I'm submitting a...


[ ] Regression
[X] Bug report
[ ] Feature request
[X] Documentation issue or request
[ ] Support request

Current behavior

[5:35:48 PM] File change detected. Starting incremental compilation...
[5:36:02 PM] Found 0 errors. Watching for file changes.

/code/api/node_modules/@nestjs/ng-universal/dist/angular-universal.module.js:26
const common_1 = require("@angular/common");
                 ^
Error [ERR_REQUIRE_ESM]: require() of ES Module /code/api/node_modules/@angular/common/fesm2015/common.mjs not supported.
Instead change the require of /code/api/node_modules/@angular/common/fesm2015/common.mjs to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (/code/api/node_modules/@nestjs/ng-universal/dist/angular-universal.module.js:26:18)
    at Object.<anonymous> (/code/api/node_modules/@nestjs/ng-universal/dist/index.js:13:14)
    at Object.<anonymous> (/code/api/node_modules/@nestjs/ng-universal/index.js:6:10)
    at Object.<anonymous> (/code/api/api_dist/server/app.module.js:15:24)
    at Object.<anonymous> (/code/api/api_dist/main.js:15:22)

Expected behavior

No TS error.

Minimal reproduction of the problem with instructions

import { AngularUniversalModule } from '@nestjs/ng-universal';

Environment

Nest version: 8.2.6

For Tooling issues:
- Node version: v16.13.1
- Platform:  Linux (inside Docker)

package.json:

{
    "dependencies": {
        "@angular/animations": "~13.2.2",
        "@angular/cdk": "^13.2.2",
        "@angular/common": "~13.2.2",
        "@angular/compiler": "~13.2.2",
        "@angular/core": "~13.2.2",
        "@angular/forms": "~13.2.2",
        "@angular/material": "^13.2.2",
        "@angular/platform-browser": "~13.2.2",
        "@angular/platform-browser-dynamic": "~13.2.2",
        "@angular/platform-server": "~13.2.2",
        "@angular/router": "~13.2.2",
        "@casl/ability": "^5.4.3",
        "@nestjs/bull": "^0.4.2",
        "@nestjs/common": "^8.2.6",
        "@nestjs/core": "^8.2.6",
        "@nestjs/jwt": "^8.0.0",
        "@nestjs/ng-universal": "^6.0.0",
        "@nestjs/passport": "^8.1.0",
        "@nestjs/platform-express": "^8.2.6",
        "@nestjs/platform-socket.io": "^8.2.6",
        "@nestjs/schedule": "^1.0.2",
        "@nestjs/serve-static": "^2.2.2",
        "@nguniversal/express-engine": "^13.0.2",
        "@typegoose/typegoose": "^9.5.0",
        "express": "^4.15.2",
        "http-proxy": "^1.18.1",
        "redis": "^3.1.2",
        "reflect-metadata": "^0.1.13",
        "rimraf": "^3.0.2",
        "rxjs": "^7.5.2",
        "tslib": "^2.3.1",
        "zone.js": "~0.11.4"
    },
    "devDependencies": {
        "@nestjs/cli": "^8.2.0",
        "@nestjs/schematics": "^8.0.5",
        "@nestjs/testing": "^8.2.6",
        "ts-node": "^10.4.0",
        "typescript": "^4.5.5"
    }
}
LeParadoxHD commented 2 years ago

Looks like it's because NestJS is only compatible with CommonJS and Angular 13+ removed support for CommonJS, so this issue should be resolved once it supports ESM imports.

nestjs/nest#8736

chancezeus commented 2 years ago

Wanting to have Angular Universal support in NestJS for a while and after quite a few failed attempts I managed to get it working.

To get it to work you'd create a helper function that uses the function constructor to generate a lazy import that won't be messed up by Typescript/Webpack (Typescript/Webpack mangle regular lazy imports to require calls) like this:

export loadEsmModule<T>(modulePath: string | URL): Promise<T> {
    return new Function('modulePath', 'return import(modulePath);')(modulePath);
}

This is similar to how @nguniversal/builders (and other @angular modules) import things when ESM is not (yet) supported (see for example: https://github.com/angular/universal/blob/main/modules/builders/src/utils/utils.ts)

Now whenever using something that comes from @angular (or @nguniversal) libraries (or depends on it), instead of using a regular import {APP_BASE_HREF} from '@angular/common'; or import {Whatever} from 'somethingThatDependsOnAngular'; you would replace that with a const {Whatever} = await loadEsmModule('somethingThatDependsOnAngular');.

This in itself makes it work but it comes at a price:

  1. Since we need an await, you'd either need to allow "top-level await" (requires NodeJS >= 16) or we need to wrap it in a async function (meaning that the forRoot should be changed or a forRootAsync should be added with "factory" support)
  2. The Angular compiler injects additional stuff into the resulting js files, this means for this to function properly, we need to explicitly "lazy import" (using the same loadEsmModule) the ngExpressEngine from the compiled results meaning that it'll have to be exported from the src/main.server.ts and then explicitly provided in the options

I've put my quickly hacked version below: File: interfaces/angular-universal-options.interface.ts

import { AngularUniversalOptions as BaseOptions } from '@nestjs/ng-universal/dist/interfaces/angular-universal-options.interface';
import { ngExpressEngine } from '@nguniversal/express-engine';

export interface AngularUniversalOptions extends BaseOptions {
  ngExpressEngine: typeof ngExpressEngine;
}

File: utils/setup-universal.utils.ts

import { Logger } from '@nestjs/common';
import { CacheKeyByOriginalUrlGenerator } from '@nestjs/ng-universal/dist/cache/cahce-key-by-original-url.generator';
import { InMemoryCacheStorage } from '@nestjs/ng-universal/dist/cache/in-memory-cache.storage';
import { CacheKeyGenerator } from '@nestjs/ng-universal/dist/interfaces/cache-key-generator.interface';
import { CacheStorage } from '@nestjs/ng-universal/dist/interfaces/cache-storage.interface';
import * as express from 'express';
import { Express, Request } from 'express';

import { AngularUniversalOptions } from '../interfaces/angular-universal-options.interface';

const DEFAULT_CACHE_EXPIRATION_TIME = 60000; // 60 seconds

const logger = new Logger('AngularUniversalModule');

export function setupUniversal(
  app: Express,
  ngOptions: AngularUniversalOptions
) {
  const cacheOptions = getCacheOptions(ngOptions);

  app.engine('html', (_, opts, callback) => {
    const options = opts as unknown as Record<string, unknown>;
    let cacheKey: string | undefined;
    if (cacheOptions.isEnabled) {
      const cacheKeyGenerator = cacheOptions.keyGenerator;
      cacheKey = cacheKeyGenerator.generateCacheKey(options['req']);

      const cacheHtml = cacheOptions.storage.get(cacheKey);
      if (cacheHtml) {
        return callback(null, cacheHtml);
      }
    }

    ngOptions.ngExpressEngine({
      bootstrap: ngOptions.bootstrap,
      inlineCriticalCss: ngOptions.inlineCriticalCss,
      providers: [
        {
          provide: 'serverUrl',
          useValue: `${(options['req'] as Request).protocol}://${(
            options['req'] as Request
          ).get('host')}`,
        },
        ...(ngOptions.extraProviders || []),
      ],
    })(_, options, (err, html) => {
      if (err && ngOptions.errorHandler) {
        return ngOptions.errorHandler({
          err,
          html,
          renderCallback: callback,
        });
      }

      if (err) {
        logger.error(err);

        return callback(err);
      }

      if (cacheOptions.isEnabled && cacheKey) {
        cacheOptions.storage.set(cacheKey, html ?? '', cacheOptions.expiresIn);
      }

      callback(null, html);
    });
  });

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

  // Serve static files
  app.get(
    ngOptions.rootStaticPath ?? '*.*',
    express.static(ngOptions.viewsPath, {
      maxAge: 600,
    })
  );
}

type CacheOptions =
  | { isEnabled: false }
  | {
      isEnabled: true;
      storage: CacheStorage;
      expiresIn: number;
      keyGenerator: CacheKeyGenerator;
    };

export function getCacheOptions(
  ngOptions: AngularUniversalOptions
): CacheOptions {
  if (!ngOptions.cache) {
    return {
      isEnabled: false,
    };
  }

  if (typeof ngOptions.cache !== 'object') {
    return {
      isEnabled: true,
      storage: new InMemoryCacheStorage(),
      expiresIn: DEFAULT_CACHE_EXPIRATION_TIME,
      keyGenerator: new CacheKeyByOriginalUrlGenerator(),
    };
  }

  return {
    isEnabled: true,
    storage: ngOptions.cache.storage || new InMemoryCacheStorage(),
    expiresIn: ngOptions.cache.expiresIn || DEFAULT_CACHE_EXPIRATION_TIME,
    keyGenerator:
      ngOptions.cache.keyGenerator || new CacheKeyByOriginalUrlGenerator(),
  };
}

File: angular-universal.module.ts

import { DynamicModule, Inject, Module, OnModuleInit } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ANGULAR_UNIVERSAL_OPTIONS } from '@nestjs/ng-universal/dist/angular-universal.constants';
import { existsSync } from 'fs';
import { join } from 'path';

import { loadEsmModule } from '../utils/load-esm-module';

import { angularUniversalProviders } from './angular-universal.providers';
import { AngularUniversalOptions } from './interfaces/angular-universal-options.interface';

@Module({
  providers: [...angularUniversalProviders],
})
export class AngularUniversalModule implements OnModuleInit {
  static forRoot(
    configFactory: () =>
      | AngularUniversalOptions
      | Promise<AngularUniversalOptions>
  ): DynamicModule {
    const factory = async (): Promise<AngularUniversalOptions> => {
      const options = await configFactory();

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

      return {
        templatePath: indexHtml,
        rootStaticPath: '*.*',
        renderPath: '*',
        ...options,
      };
    };

    return {
      module: AngularUniversalModule,
      providers: [
        {
          provide: ANGULAR_UNIVERSAL_OPTIONS,
          useFactory: factory,
        },
      ],
    };
  }

  constructor(
    @Inject(ANGULAR_UNIVERSAL_OPTIONS)
    private readonly ngOptions: AngularUniversalOptions,
    private readonly httpAdapterHost: HttpAdapterHost
  ) {}

  async onModuleInit() {
    const { APP_BASE_HREF } = await loadEsmModule<
      typeof import('@angular/common')
    >('@angular/common');

    if (!this.httpAdapterHost) {
      return;
    }

    const httpAdapter = this.httpAdapterHost.httpAdapter;
    if (!httpAdapter) {
      return;
    }

    const app = httpAdapter.getInstance();

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    app.get(this.ngOptions.renderPath, (req: any, res: any) =>
      res.render(this.ngOptions.templatePath, {
        req,
        res,
        providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
      })
    );
  }
}

File: angular-universal.providers.ts

import { Provider } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ANGULAR_UNIVERSAL_OPTIONS } from '@nestjs/ng-universal/dist/angular-universal.constants';

import { AngularUniversalOptions } from './interfaces/angular-universal-options.interface';
import { setupUniversal } from './utils/setup-universal.utils';

export const angularUniversalProviders: Provider[] = [
  {
    provide: 'UNIVERSAL_INITIALIZER',
    useFactory: (
      host: HttpAdapterHost,
      options: AngularUniversalOptions & { template: string }
    ) =>
      host &&
      host.httpAdapter &&
      setupUniversal(host.httpAdapter.getInstance(), options),
    inject: [HttpAdapterHost, ANGULAR_UNIVERSAL_OPTIONS],
  },
];

I've done this as a new module in my project, replacing the @nestjs/ng-universal parts that gave issues.

Note: as indicated, this is just a quick "hack" to see if I could get it to work using (mostly) the existing implementation, obviously it'll need some cleanup, I posted it mainly as inspiration/poc for the devs.

Now to use it, I'd do something like:

AngularUniversalModule.forRoot(async () => {
    const angularModule = await loadEsmModule<{default: typeof import('../../src/main.server')}>(join(process.cwd(), 'dist/ProjectName/server/main.js'));

    return {
        bootstrap: angularModule.default.AppServerModule,
        ngExpressEngine: angularModule.default.ngExpressEngine,
        viewsPath: join(process.cwd(), 'dist/ProjectName/browser'),
    };
}),

Note: this also applies to the situation where the nestjs and angular projects are separate (for example using Nx), only thing that might change is the paths to angular sources and server/browser dist versions.

hiepxanh commented 2 years ago

Wow, that is so clear and elegant, that code is nice and really help for me. thank you for that @chancezeus 👍 ❤️

|

AngularUniversalModule.forRoot(async () => {
const angularModule = await loadEsmModule<{default: typeof import('../../src/main.server')}>(join(process.cwd(), 'dist/ProjectName/server/main.js'));

|

return {
    bootstrap: angularModule.default.AppServerModule,
    ngExpressEngine: angularModule.default.ngExpressEngine,
    viewsPath: join(process.cwd(), 'dist/ProjectName/browser'),
};

}),

florinmtsc commented 1 year ago

I am having the same problem, I also use nx:

node_modules/.pnpm/@nx+js@16.7.4_@swc+core@1.3.78_@types+node@20.5.7_nx@16.7.4_typescript@5.1.6/node_modules/@nx/js/src/executors/node/node-with-require-overrides.js:18
        return originalLoader.apply(this, arguments);

Did you find a solution for this without rewriting stuff from the ng-universal?