| 1 | import type { DynamicModule, Provider, Type } from '@nestjs/common' |
| 2 | import type { NestApplication } from '@nestjs/core' |
| 3 | import type { NestExpressApplication } from '@nestjs/platform-express' |
| 4 | import type { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' |
| 5 | import type { Request, Response } from 'express' |
| 6 | import type { IncomingMessage, ServerResponse } from 'node:http' |
| 7 | import type { StreamEntry } from 'pino' |
| 8 | import type { BaseConfig } from './config' |
| 9 | import { HttpStatus, Logger, Module, VersioningType } from '@nestjs/common' |
| 10 | import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE, NestFactory } from '@nestjs/core' |
| 11 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' |
| 12 | import { apiReference } from '@scalar/nestjs-api-reference' |
| 13 | import { customAlphabet } from 'nanoid' |
| 14 | import { LoggerModule, Logger as PinoLogger } from 'nestjs-pino' |
| 15 | import pino from 'pino' |
| 16 | import client from 'prom-client' |
| 17 | import { z } from 'zod' |
| 18 | import { GlobalExceptionFilter } from './filters' |
| 19 | import { HttpMetricsInterceptor, PropagationInterceptor, RequestContextInterceptor, ResponseInterceptor } from './interceptors' |
| 20 | import { CloudWatchLogger, ConsoleLogger, FeishuLogger, serializeError } from './loggers' |
| 21 | import { ZodValidationPipe } from './pipes' |
| 22 | import { patchNestJsSwagger, zodToJsonSchemaOptions } from './utils' |
| 23 | |
| 24 | import './utils/load-file-from-env.util' |
| 25 | |
| 26 | z.config(z.locales.zhCN()) |
| 27 | |
| 28 | patchNestJsSwagger() |
| 29 | |
| 30 | const logger = new Logger('Bootstrap') |
| 31 | |
| 32 | function setupMetrics(app: NestApplication & NestExpressApplication) { |
| 33 | client.collectDefaultMetrics() |
| 34 | app.use('/metrics', async (_req: Request, res: Response) => { |
| 35 | res.set('Content-Type', client.register.contentType) |
| 36 | const metrics = await client.register.metrics() |
| 37 | res.end(metrics) |
| 38 | }) |
| 39 | } |
| 40 | |
| 41 | function preserveRawBody(req: Request & { rawBody?: Buffer }, _res: Response, buf: Buffer) { |
| 42 | if (buf.length > 0) { |
| 43 | req.rawBody = Buffer.from(buf) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | @Module({}) |
| 48 | class RootModule { |
| 49 | static setup(args: Omit<DynamicModule, 'module'>): DynamicModule { |
| 50 | return { |
| 51 | module: RootModule, |
| 52 | ...args, |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | export interface StartApplicationOptions { |
| 58 | setupOpenapi?: (builder: DocumentBuilder) => DocumentBuilder |
| 59 | setupApp?: (app: NestApplication) => void |
| 60 | } |
| 61 | export async function startApplication(Module: Type<unknown>, config: BaseConfig, options: StartApplicationOptions = {}) { |
| 62 | if (config.enableConfigLogging) { |
| 63 | logger.log(JSON.stringify(config, null, 2)) |
| 64 | } |
| 65 | const loggers: StreamEntry[] = [] |
| 66 | |
| 67 | if (config.logger?.console?.enable) { |
| 68 | loggers.push({ |
| 69 | level: config.logger.console.level, |
| 70 | stream: new ConsoleLogger(config.logger.console), |
| 71 | }) |
| 72 | } |
| 73 | |
| 74 | if (config.logger?.cloudWatch?.enable) { |
| 75 | loggers.push({ |
| 76 | level: config.logger.cloudWatch.level, |
| 77 | stream: new CloudWatchLogger(config.logger.cloudWatch), |
| 78 | }) |
| 79 | } |
| 80 | |
| 81 | if (config.logger?.feishu?.enable) { |
| 82 | loggers.push({ |
| 83 | level: config.logger.feishu.level, |
| 84 | stream: new FeishuLogger(config.logger.feishu), |
| 85 | }) |
| 86 | } |
| 87 | |
| 88 | const reqIdGenerator = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21) |
| 89 | |
| 90 | const imports: DynamicModule[] = [ |
| 91 | LoggerModule.forRoot({ |
| 92 | pinoHttp: [ |
| 93 | { |
| 94 | level: 'trace', |
| 95 | serializers: { |
| 96 | err: serializeError, |
| 97 | error: serializeError, |
| 98 | }, |
| 99 | genReqId: (req: IncomingMessage, res: ServerResponse) => { |
| 100 | const incomingRequestId = req.headers['x-request-id'] |
| 101 | const requestId = typeof incomingRequestId === 'string' |
| 102 | ? incomingRequestId |
| 103 | : Array.isArray(incomingRequestId) |
| 104 | ? incomingRequestId[0] |
| 105 | : reqIdGenerator() |
| 106 | req.headers['x-request-id'] = requestId |
| 107 | if (!res.headersSent) { |
| 108 | res.setHeader('x-request-id', requestId) |
| 109 | } |
| 110 | return requestId |
| 111 | }, |
| 112 | }, |
| 113 | pino.multistream(loggers), |
| 114 | ], |
| 115 | }), |
| 116 | ] |
| 117 | const providers: Provider[] = [ |
| 118 | { |
| 119 | provide: APP_INTERCEPTOR, |
| 120 | useClass: HttpMetricsInterceptor, |
| 121 | }, |
| 122 | { |
| 123 | provide: APP_INTERCEPTOR, |
| 124 | useClass: RequestContextInterceptor, |
| 125 | }, |
| 126 | /** |
| 127 | * 传播上下文 |
| 128 | */ |
| 129 | { |
| 130 | provide: APP_INTERCEPTOR, |
| 131 | useClass: PropagationInterceptor, |
| 132 | }, |
| 133 | { |
| 134 | provide: APP_PIPE, |
| 135 | useClass: ZodValidationPipe, |
| 136 | }, |
| 137 | { |
| 138 | provide: APP_INTERCEPTOR, |
| 139 | useClass: ResponseInterceptor, |
| 140 | }, |
| 141 | { |
| 142 | provide: APP_FILTER, |
| 143 | useValue: new GlobalExceptionFilter({ |
| 144 | returnBadRequestDetails: config.enableBadRequestDetails, |
| 145 | }), |
| 146 | }, |
| 147 | ] |
| 148 | |
| 149 | const app = await NestFactory.create< |
| 150 | NestApplication & NestExpressApplication |
| 151 | >(RootModule.setup({ |
| 152 | imports: [...imports, Module], |
| 153 | providers, |
| 154 | }), { |
| 155 | cors: true, |
| 156 | }) |
| 157 | |
| 158 | app.useLogger(app.get(PinoLogger)) |
| 159 | |
| 160 | app.enableVersioning({ type: VersioningType.URI }) |
| 161 | |
| 162 | if (config.globalPrefix) |
| 163 | app.setGlobalPrefix(config.globalPrefix, { exclude: ['/'] }) |
| 164 | |
| 165 | if (options.setupApp) { |
| 166 | options.setupApp(app) |
| 167 | } |
| 168 | |
| 169 | if (config.openapi?.enable) { |
| 170 | const builder = new DocumentBuilder() |
| 171 | .setTitle(config.openapi.title) |
| 172 | .setDescription(config.openapi.description) |
| 173 | .setOpenAPIVersion('3.0.0') |
| 174 | .addBearerAuth() |
| 175 | |
| 176 | if (options.setupOpenapi) { |
| 177 | options.setupOpenapi(builder) |
| 178 | } |
| 179 | |
| 180 | const openApiDocument = SwaggerModule.createDocument( |
| 181 | app, |
| 182 | builder.build(), |
| 183 | ) |
| 184 | |
| 185 | if (openApiDocument.components?.schemas) { |
| 186 | const zodSchemas = z.toJSONSchema(z.globalRegistry, { ...zodToJsonSchemaOptions, io: 'input' }).schemas |
| 187 | Object.keys(zodSchemas).forEach((key) => { |
| 188 | const schema = zodSchemas[key] |
| 189 | delete schema.$id |
| 190 | }) |
| 191 | openApiDocument.components.schemas = { |
| 192 | ...openApiDocument.components.schemas, |
| 193 | ...zodSchemas as Record<string, SchemaObject>, |
| 194 | } |
| 195 | } |
| 196 | app.use( |
| 197 | `${config.openapi.path}/openapi.json`, |
| 198 | (_req: Request, res: Response) => { res.json(openApiDocument) }, |
| 199 | ) |
| 200 | |
| 201 | app.use( |
| 202 | config.openapi.path, |
| 203 | apiReference({ |
| 204 | persistAuth: true, |
| 205 | content: openApiDocument, |
| 206 | }), |
| 207 | ) |
| 208 | } |
| 209 | |
| 210 | app.enableShutdownHooks() |
| 211 | |
| 212 | app.getHttpAdapter().get('/health', (_req, res) => res.status(HttpStatus.OK).send('OK')) |
| 213 | setupMetrics(app) |
| 214 | |
| 215 | let closing: Promise<void> | undefined |
| 216 | app.getHttpAdapter().all('/_shutdown', async (_req: Request, res: Response) => { |
| 217 | res.status(HttpStatus.OK).send('Shutting down...') |
| 218 | if (closing) |
| 219 | return |
| 220 | closing = app.close() |
| 221 | await closing |
| 222 | process.exit(0) |
| 223 | }) |
| 224 | |
| 225 | app.useBodyParser('text', { |
| 226 | limit: '50mb', |
| 227 | type: ['text/*', 'application/xml', 'application/atom+xml'], |
| 228 | verify: preserveRawBody, |
| 229 | }) |
| 230 | app.useBodyParser('json', { limit: '50mb', verify: preserveRawBody }) |
| 231 | app.useBodyParser('urlencoded', { limit: '50mb', extended: true, verify: preserveRawBody }) |
| 232 | app.set('query parser', 'extended') |
| 233 | |
| 234 | app.disable('x-powered-by') |
| 235 | |
| 236 | app.enable('trust proxy') |
| 237 | |
| 238 | process.on('uncaughtException', (reason) => { |
| 239 | logger.error(reason) |
| 240 | }) |
| 241 | process.on('unhandledRejection', (reason) => { |
| 242 | logger.error(reason, 'Unhandled Rejection') |
| 243 | }) |
| 244 | process.on('exit', (code) => { |
| 245 | logger.log(`app exiting with code ${code}`) |
| 246 | }) |
| 247 | |
| 248 | await app.startAllMicroservices() |
| 249 | await app.listen(config.port, () => { |
| 250 | logger.log(`app started at port ${config.port}`) |
| 251 | if (config.openapi?.enable) |
| 252 | logger.log(`swagger docs: http://localhost:${config.port}${config.openapi.path}`) |
| 253 | }) |
| 254 | } |
| 255 |