Open LeParadoxHD opened 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
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:
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)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 optionsI'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.
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'),
};
}),
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?
I'm submitting a...
Current behavior
Expected behavior
No TS error.
Minimal reproduction of the problem with instructions
Environment
package.json: