diff --git a/.nvmrc b/.nvmrc index 8e6ddac..bf79505 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v12.8.0 +v16.14.0 diff --git a/package.json b/package.json index 87843dc..1a7601b 100644 --- a/package.json +++ b/package.json @@ -1,68 +1,86 @@ { "name": "@neoskop/nestjs", - "version": "0.1.0", + "version": "0.2.0", "description": "Collection of useful NestJS Modules", "main": "index.js", "repository": "https://github.com/neoskop/nestjs", "author": "Mark Wecke ", "license": "MIT", "scripts": { - "prebuild": "rm -r dist", + "prebuild": "rm -rf dist", "build": "tsc", "postbuild": "jq 'del(.scripts) | del(.devDependencies)' package.json > dist/package.json && cp README.md dist", - "publish-next": "npm run build && npm publish dist --tag next", - "publish-latest-only": "npm run build && npm publish dist", + "publish-next": "npm run build && cd dist && npm publish --tag next", + "publish-latest-only": "npm run build && cd dist && npm publish", "publish-latest": "npm run publish-latest-only && npm dist-tag add @neoskop/nestjs@`jq '.version' package.json -r` next" }, "devDependencies": { - "@angular/core": "7.2.14", - "@angular/platform-server": "7.2.14", - "@angular/router": "7.2.14", + "@angular/common": "12", + "@angular/core": "12", + "@angular/platform-server": "12", + "@angular/router": "12", "@neoskop/adamant": "0.3.0-rc.6", "@neoskop/annotation-factory": "1.0.1", - "@neoskop/hrbac": "1.0.1", + "@neoskop/hrbac": "2.0.0-rc.1", "@neoskop/phantom": "1.3.0", - "@nestjs/common": "6.1.1", - "@nestjs/core": "6.1.1", - "@nestjs/graphql": "6.0.5", - "@nguniversal/module-map-ngfactory-loader": "7.1.1", - "@types/cookie-parser": "1.4.1", - "@types/express": "4.16.1", - "@types/http-proxy-middleware": "0.19.2", + "@nestjs/common": "8", + "@nestjs/core": "8", + "@nestjs/graphql": "8", + "@nestjs/terminus": "8", + "@types/cookie-parser": "1.4.2", + "@types/express": "4.17.1", + "@types/http-proxy-middleware": "^1.0.0", "@types/json-schema": "7.0.3", - "@types/node": "12.0.0", + "@types/node": "12.7.5", + "@types/oauth2-server": "3.0.12", + "@types/pouchdb": "6.4.0", "@types/simple-oauth2": "2.2.1", "@types/winston": "2.4.4", - "ajv": "6.10.0", + "ajv": "6.10.2", "cookie-parser": "^1.4.4", - "http-proxy-middleware": "0.19.1", + "http-proxy-middleware": "^2.0.0", + "oauth2-server": "3.0.1", "reflect-metadata": "0.1.13", - "rxjs": "6.5.1", - "simple-oauth2": "2.2.1", - "typescript": "3.4.5", + "rxjs": "6.5.3", + "simple-oauth2": "2.4.0", + "typescript": "^4.2.4", "winston": "3.2.1", - "zone.js": "0.9.1" + "zone.js": "0.10.2" }, "dependencies": { - "tslib": "1.9.3" + "import-fresh": "^3.3.0", + "tslib": "^2.2.0" }, "peerDependencies": { + "@angular/common": "*", "@angular/core": "*", - "@angular/platform-server": "^7.2.9", - "@neoskop/adamant": "^0.3.0-rc.5", - "@neoskop/hrbac": "^1.0.1", - "@neoskop/phantom": "^1.3.0", - "@nestjs/common": "^6.1.1", - "@nestjs/core": "^6.1.1", - "@nestjs/graphql": "^6.0.5", - "@nguniversal/module-map-ngfactory-loader": "^7.1.1", - "ajv": "^6.10.0", - "cookie-parser": "^1.4.4", - "http-proxy-middleware": "^0.19.1", - "reflect-metadata": "^0.1.12", - "rxjs": "^6.0.0", - "simple-oauth2": "^2.2.1", - "winston": "^3.2.1", - "zone.js": "0.9.1" + "@angular/platform-server": "*", + "@neoskop/adamant": "*", + "@neoskop/hrbac": ">=2.0.0-rc.1", + "@neoskop/phantom": "*", + "@nestjs/common": "*", + "@nestjs/core": "*", + "@nestjs/graphql": "*", + "@nestjs/terminus": "*", + "ajv": "*", + "cookie-parser": "*", + "http-proxy-middleware": "*", + "oauth2-server": "*", + "reflect-metadata": "*", + "rxjs": "*", + "simple-oauth2": "*", + "winston": "*", + "zone.js": "*" + }, + "prettier": { + "trailingComma": "none", + "tabWidth": 4, + "arrowParens": "avoid", + "bracketSpacing": true, + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css", + "printWidth": 140, + "quoteProps": "as-needed", + "singleQuote": true } } \ No newline at end of file diff --git a/src/adamant/adamant.health.ts b/src/adamant/adamant.health.ts new file mode 100644 index 0000000..da1eb23 --- /dev/null +++ b/src/adamant/adamant.health.ts @@ -0,0 +1,40 @@ +import { AdamantConnectionManager } from '@neoskop/adamant'; +import { Injectable } from '@nestjs/common'; +import { HealthIndicator } from '@nestjs/terminus'; +import url from 'url'; + +const TIMEOUT = Symbol.for('Timeout'); + +@Injectable() +export class AdamantHealthIndicator extends HealthIndicator { + constructor(protected readonly manager : AdamantConnectionManager) { + super(); + } + + async pingCheck(key: string, { timeout = 1000 }: { timeout?: number } = {}) { + let isHealthy = true; + const info : { [key: string]: boolean } = {} + for(const conn of this.manager.getOpenConnections()) { + let isConnectionHealthy : boolean; + try { + const res = await this.timeout(conn.info(), timeout) + isConnectionHealthy = res !== TIMEOUT; + } catch { + isConnectionHealthy = false; + } + info[url.parse(conn.name).pathname!.substr(1)] = isConnectionHealthy; + isHealthy = isHealthy && isConnectionHealthy; + } + + return this.getStatus(key, isHealthy, info); + } + + private timeout(promise : Promise, timeout : number) : Promise { + return Promise.race([ + promise, + new Promise(resolve => { + setTimeout(() => resolve(TIMEOUT), timeout) + }) + ]) + } +} \ No newline at end of file diff --git a/src/adamant/adamant.module.ts b/src/adamant/adamant.module.ts index c3fa2e9..b922308 100644 --- a/src/adamant/adamant.module.ts +++ b/src/adamant/adamant.module.ts @@ -14,17 +14,20 @@ import { } from '@neoskop/adamant'; import { DynamicModule, Global, Module, Provider, Type, Inject } from '@nestjs/common'; import { ModuleMetadata } from '@nestjs/common/interfaces'; +import { AdamantHealthIndicator } from './adamant.health'; export interface AdamantOptions { factory: ConnectionFactory; designDocs?: Type[]; providers?: any[]; + viewWarmup?: 'sync' | 'async' | 'none' | false | null } export interface AdamantAsyncOptions extends Pick { providers?: any[]; designDocs?: Type[]; + viewWarmup?: 'sync' | 'async' | 'none' | false | null useExisting?: Type; useClass?: Type; useFactory?: (...args : any[]) => Promise | ConnectionFactory, @@ -38,6 +41,7 @@ export interface AdamantConnectionFactoryFactory { export const ADAMANT_PROVIDERS = 'ADAMANT_PROVIDERS'; export const ADAMANT_DESIGN_DOCS = 'ADAMANT_DESIGN_DOCS'; export const ADAMANT_PROVIDER_VALUES = 'ADAMANT_PROVIDER_VALUES'; +export const ADAMANT_VIEW_WARMUP = 'ADAMANT_VIEW_WARMUP'; export async function designDocFactory(...designDocs : any[]) { return designDocs; @@ -48,7 +52,7 @@ export async function designDocFactory(...designDocs : any[]) { providers: [ { provide: AdamantConnectionManager, - async useFactory(factory: ConnectionFactory, providers : any[], designDocs : any[], deps : any[]) { + async useFactory(factory: ConnectionFactory, providers : any[], designDocs : any[], deps : any[], viewWarmUp: 'sync' | 'async' | 'none' | false) { const injector = Injector.create({ providers: [ { provide: ADAMANT_ID, useFactory: adamantIdFactory, deps: [] }, @@ -65,8 +69,17 @@ export async function designDocFactory(...designDocs : any[]) { const metadata = DesignDocMetadataCollection.create(designDoc.constructor as any); await manager.getRepository(metadata.entity).persistDesignDoc(designDoc); - for(const view of metadata.views) { - await manager.getRepository(metadata.entity).view(designDoc.constructor as any, view, { depth: 0 }); + if(viewWarmUp === 'sync' || viewWarmUp === 'async') { + const opts : { stale?: 'update_after' } = {}; + + if(viewWarmUp === 'async') { + opts.stale = 'update_after'; + } + + for(const view of metadata.views) { + await manager.getRepository(metadata.entity).view(designDoc.constructor as any, view, { depth: 0, ...opts }); + } + } } } catch(e) { @@ -75,10 +88,11 @@ export async function designDocFactory(...designDocs : any[]) { return manager; }, - inject: [ ADAMANT_CONNECTION_FACTORY, ADAMANT_PROVIDERS, ADAMANT_DESIGN_DOCS, ADAMANT_PROVIDER_VALUES ] - } + inject: [ ADAMANT_CONNECTION_FACTORY, ADAMANT_PROVIDERS, ADAMANT_DESIGN_DOCS, ADAMANT_PROVIDER_VALUES, ADAMANT_VIEW_WARMUP ] as (string|Type)[] + }, + AdamantHealthIndicator ], - exports: [ AdamantConnectionManager ] + exports: [ AdamantConnectionManager, AdamantHealthIndicator ] }) export class AdamantModule { @@ -88,6 +102,7 @@ export class AdamantModule { providers: [ { provide: ADAMANT_CONNECTION_FACTORY as any, useValue: options.factory }, { provide: ADAMANT_PROVIDERS, useValue: options.providers || [] }, + { provide: ADAMANT_VIEW_WARMUP, useValue: null == options.viewWarmup ? 'sync' : options.viewWarmup }, { provide: ADAMANT_PROVIDER_VALUES, useFactory: (...deps: any[]) => deps, inject: options.providers || [] }, { provide: ADAMANT_DESIGN_DOCS, @@ -105,6 +120,7 @@ export class AdamantModule { providers: [ ...this.createAsyncProviders(options), { provide: ADAMANT_PROVIDERS, useValue: options.providers || [] }, + { provide: ADAMANT_VIEW_WARMUP, useValue: null == options.viewWarmup ? 'sync' : options.viewWarmup }, { provide: ADAMANT_PROVIDER_VALUES, useFactory: (...deps: any[]) => deps, inject: options.providers || [] }, { provide: ADAMANT_DESIGN_DOCS, @@ -142,7 +158,7 @@ export class AdamantModule { return { provide: ADAMANT_CONNECTION_FACTORY as any, useFactory: async (factory : AdamantConnectionFactoryFactory) => await factory.createAdamantConnection(), - inject: [ options.useExisting || options.useClass ] + inject: [ options.useExisting || options.useClass! ] } } } diff --git a/src/adamant/index.ts b/src/adamant/index.ts index a03a389..dddc9c5 100644 --- a/src/adamant/index.ts +++ b/src/adamant/index.ts @@ -1 +1,2 @@ -export * from './adamant.module'; \ No newline at end of file +export * from './adamant.module'; +export * from './adamant.health'; \ No newline at end of file diff --git a/src/angular/angular-locale.controller.ts b/src/angular/angular-locale.controller.ts index 3d808a0..f1c6735 100644 --- a/src/angular/angular-locale.controller.ts +++ b/src/angular/angular-locale.controller.ts @@ -31,10 +31,10 @@ export class AngularLocaleController { } for(const locale in parseAcceptLanguageHeader(req.get('accept-language') || '')) { if(this.options.locales.includes(locale)) { - return res.redirect(`/${locale}`); + return res.redirect(`/${locale}${req.path}`); } } - res.redirect(`/${this.options.defaultLocale}`); + res.redirect(`/${this.options.defaultLocale}${req.path}`); }) } diff --git a/src/angular/angular-root.controller.ts b/src/angular/angular-root.controller.ts index 0a664e8..37f34ce 100644 --- a/src/angular/angular-root.controller.ts +++ b/src/angular/angular-root.controller.ts @@ -12,12 +12,12 @@ export class AngularRootController extends AngularController { constructor(@Inject(ANGULAR_OPTIONS) options : AngularOptions) { - super(options.mode, options, options.nonceFactory); + super(options.mode, options, options.nonceFactory, options.hooks); } protected init() { for(const [ path, options ] of this.options.apps) { - const controller = new AngularController(this.options.mode, options, this.options.nonceFactory); + const controller = new AngularController(this.options.mode, options, this.options.nonceFactory, this.options.hooks); this.router.use(path, controller.handle.bind(controller)); } diff --git a/src/angular/angular.controller.ts b/src/angular/angular.controller.ts index f58c643..0fae622 100644 --- a/src/angular/angular.controller.ts +++ b/src/angular/angular.controller.ts @@ -1,36 +1,58 @@ import 'zone.js'; import 'zone.js/dist/zone-node'; -import { NgModuleFactory } from '@angular/core'; -import { renderModuleFactory } from '@angular/platform-server'; +import { Type } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; +import { renderModule, INITIAL_CONFIG } from '@angular/platform-server'; import { Controller, Get, Next, Request, Response } from '@nestjs/common'; -import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader'; -import { ModuleMap } from '@nguniversal/module-map-ngfactory-loader/src/module-map'; import express from 'express'; import fs from 'fs'; -import proxy from 'http-proxy-middleware'; +import { createProxyMiddleware } from 'http-proxy-middleware'; import path from 'path'; +import importFresh from 'import-fresh'; import { IAngularAppOptions } from './tokens'; +import { IncomingMessage } from 'http'; + +export interface IHooks { + pre?(request: express.Request, response: express.Response): void | Promise; + post?(body: string, request: express.Request, response: express.Response): void | string | Promise; + zoneProperties?(request: express.Request, response: express.Response): void | Record | Promise< | Record>; + onProxyRes?(proxyRes: IncomingMessage, request: express.Request, response: express.Response): void | Record | Promise< | Record>; + proxyPathRewrite?: Record | ((path: string, req: express.Request) => string) | ((path: string, req: express.Request) => Promise); +} @Controller() export class AngularController { - protected readonly _static = this.mode === 'ssr' ? express.static(path.resolve(this.options.www), { index: false }) : null; - protected readonly _template = this.mode === 'ssr' ? fs.readFileSync(path.join(this.options.www, 'index.html'), 'utf-8') : null; - protected readonly _bundle = this.mode === 'ssr' ? loadBundle(this.options.main) : null; - protected readonly _proxy = this.mode === 'proxy' ? proxy({ target: this.options.target, changeOrigin: true, ws: true }) : null; + protected readonly _static = + this.mode === 'ssr' && this.options.www ? express.static(path.resolve(this.options.www), { index: false }) : null; + protected readonly _template = + this.mode === 'ssr' && this.options.www ? fs.readFileSync(path.join(this.options.www, 'index.html'), 'utf-8') : null; + protected readonly _proxy = + this.mode === 'proxy' && this.options.target ? createProxyMiddleware({ + target: this.options.target, + changeOrigin: true, + ws: true, + onProxyRes: this.hooks?.onProxyRes, + pathRewrite: this.hooks?.proxyPathRewrite + }) : null; protected readonly router = express.Router(); - constructor(protected readonly mode: 'ssr' | 'proxy', + constructor( + protected readonly mode: 'ssr' | 'proxy', protected readonly options: T, - protected readonly nonceFactory?: (request : express.Request, response : express.Response) => string) { + protected readonly nonceFactory?: (request: express.Request, response: express.Response) => string, + protected readonly hooks?: IHooks + ) { this.init(); } protected init() { - this.router.get('*.*', this.getStaticAssets.bind(this)); - this.router.get('*', this.getAngular.bind(this)); + if ((this._static && this._template) || this._proxy) { + this.router.get('*.*', this.getStaticAssets.bind(this)); + this.router.get('*', this.getAngular.bind(this)); + } } protected checkSkip(req: express.Request) { @@ -45,39 +67,63 @@ export class AngularController { - try { - const html = await renderModuleFactory(this._bundle!.ModuleNgFactory, { - document: this._template!, - url: `${request.protocol}://${request.get('host')}${request.url}`, - extraProviders: [ - provideModuleMap(this._bundle!.LAZY_MODULE_MAP), - { provide: 'REQUEST', useValue: request }, - { provide: 'RESPONSE', useValue: response } - ] - }); - - response.header('Content-Type', 'text/html'); - if(this.nonceFactory) { - response.end(html.replace(/