diff --git a/src/app.config.ts b/src/app.config.ts index 671c9f4c..02787d10 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -6,8 +6,6 @@ import { load as yamlLoad } from 'js-yaml' import { machineIdSync } from 'node-machine-id' import path from 'path' -import { isDev } from './global/env.global' - const commander = program .option('-p, --port ', 'server port') .option('--demo', 'enable demo mode') @@ -112,7 +110,7 @@ export const REDIS = { ttl: null, httpCacheTTL: 5, max: 5, - disableApiCache: (isDev || argv.disable_cache) && !ENABLE_CACHE_DEBUG, + disableApiCache: false, } export const AXIOS_CONFIG: AxiosRequestConfig = { diff --git a/src/app.module.ts b/src/app.module.ts index 9fa18e6b..76d6f3b4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { DynamicModule, Module, NestModule, Type } from '@nestjs/common' import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core' +import { ThrottlerGuard } from '@nestjs/throttler' import { DEMO_MODE } from './app.config' import { AppController } from './app.controller' @@ -141,6 +142,10 @@ import { RedisModule } from './processors/redis/redis.module' provide: APP_GUARD, useClass: RolesGuard, }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule { diff --git a/src/common/interceptors/cache.interceptor.ts b/src/common/interceptors/cache.interceptor.ts index fd983039..d09d2e08 100644 --- a/src/common/interceptors/cache.interceptor.ts +++ b/src/common/interceptors/cache.interceptor.ts @@ -58,11 +58,11 @@ export class HttpCacheInterceptor implements NestInterceptor { const handler = context.getHandler() const isDisableCache = this.reflector.get(META.HTTP_CACHE_DISABLE, handler) - const key = this.trackBy(context) || `mx-api-cache:${request.url}` if (isDisableCache) { return call$ } + const key = this.trackBy(context) || `mx-api-cache:${request.url}` const metaTTL = this.reflector.get(META.HTTP_CACHE_TTL_METADATA, handler) const ttl = metaTTL || REDIS.httpCacheTTL diff --git a/src/modules/link/link.service.ts b/src/modules/link/link.service.ts index 85ae9479..8da6896a 100644 --- a/src/modules/link/link.service.ts +++ b/src/modules/link/link.service.ts @@ -130,7 +130,7 @@ export class LinkService { if (!model.email) { return } - const enable = (await this.configs.get('mailOptions')).enable + const { enable } = await this.configs.get('mailOptions') if (!enable || isDev) { console.log(` To: ${model.email} diff --git a/src/modules/serverless/serverless.controller.ts b/src/modules/serverless/serverless.controller.ts index 952d6e6e..1a1db9cc 100644 --- a/src/modules/serverless/serverless.controller.ts +++ b/src/modules/serverless/serverless.controller.ts @@ -13,6 +13,7 @@ import { Request, Response, } from '@nestjs/common' +import { Throttle } from '@nestjs/throttler' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' @@ -49,7 +50,8 @@ export class ServerlessController { throw new InternalServerErrorException('code defined file not found') } } - @Get('/:reference/:name/*') + @All('/:reference/:name/*') + @Throttle(100, 5) @HTTPDecorators.Bypass async runServerlessFunctionWildcard( @Param() param: ServerlessReferenceDto, @@ -62,6 +64,7 @@ export class ServerlessController { } @All('/:reference/:name') + @Throttle(100, 5) @HTTPDecorators.Bypass async runServerlessFunction( @Param() param: ServerlessReferenceDto, diff --git a/src/modules/serverless/serverless.service.ts b/src/modules/serverless/serverless.service.ts index d42301aa..412bc727 100644 --- a/src/modules/serverless/serverless.service.ts +++ b/src/modules/serverless/serverless.service.ts @@ -1,6 +1,7 @@ import { isURL } from 'class-validator' import fs, { mkdir, stat } from 'fs/promises' import { isPlainObject } from 'lodash' +import LRUCache from 'lru-cache' import { createRequire } from 'module' import { mongo } from 'mongoose' import path, { resolve } from 'path' @@ -44,9 +45,21 @@ import { builtInSnippets } from './pack/built-in' import { ServerlessStorageCollectionName } from './serverless.model' import { complieTypeScriptBabelOptions, hashStable } from './serverless.util' +class CleanableScope { + public requireModuleIdSet = new Set() + public scopeContextLRU = new LRUCache({ + max: 100, + ttl: 1000 * 60 * 5, + }) + public scopeModuleLRU = new LRUCache({ + max: 20, + ttl: 1000 * 60 * 5, + }) +} @Injectable() export class ServerlessService implements OnModuleInit { private readonly logger: Logger + private readonly cleanableScope: CleanableScope constructor( @InjectModel(SnippetModel) private readonly snippetModel: MongooseModel, @@ -58,6 +71,7 @@ export class ServerlessService implements OnModuleInit { private readonly configService: ConfigsService, ) { this.logger = new Logger(ServerlessService.name) + this.cleanableScope = new CleanableScope() } async onModuleInit() { @@ -265,92 +279,15 @@ export class ServerlessService implements OnModuleInit { context: { req: FunctionContextRequest; res: FunctionContextResponse }, ) { const { raw: functionString } = model - const logger = new Logger(`fx:${model.reference}/${model.name}`) - const document = await this.model.findById(model.id) - const secretObj = model.secret - ? qs.parse(EncryptUtil.decrypt(model.secret)) - : {} + const scope = `${model.reference}/${model.name}` - if (!isPlainObject(secretObj)) { - throw new InternalServerErrorException( - `secret parsing error, must be object, got ${typeof secretObj}`, - ) - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this - const globalContext = { - context: { - // inject app req, res - ...context, - ...context.res, - query: context.req.query, - headers: context.req.headers, - // TODO wildcard params - params: Object.assign({}, context.req.params), - - storage: { - cache: this.mockStorageCache(), - db: this.mockDb( - `${model.reference || '#########debug######'}@${model.name}`, - ), - dangerousAccessDbInstance: () => { - return [this.databaseService.db, mongo] - }, - }, - - secret: secretObj, - - model, - document, - name: model.name, - reference: model.reference, - getMaster: this.mockGetMaster.bind(this), - getService: this.getService.bind(this), - - writeAsset: async ( - path: string, - data: any, - options: Parameters[2], - ) => { - return await this.assetService.writeUserCustomAsset( - safePathJoin(path), - data, - options, - ) - }, - readAsset: async ( - path: string, - options: Parameters[1], - ) => { - return await this.assetService.getAsset(safePathJoin(path), options) - }, - }, - - // inject global - __dirname: DATA_DIR, - __filename: '', - - // inject some zx utils - fetch, - - // inject Global API - Buffer, - - // inject logger - console: logger, + const logger = new Logger(`fx:${scope}`) + const globalContext = await this.createScopeContext( + scope, + context, + model, logger, - - require: this.inNewContextRequire(), - import(module: string) { - return Promise.resolve(self.require(module)) - }, - - process: { - env: Object.freeze({ ...process.env }), - nextTick: process.nextTick.bind(null), - }, - } + ) const cacheKey = model.updated ? getRedisKey( @@ -412,15 +349,17 @@ export class ServerlessService implements OnModuleInit { return res.code } - private requireModuleIdSet = new Set() - @Interval(5 * 60 * 1000) - private cleanRequireCache() { - Array.from(this.requireModuleIdSet.values()).forEach((id) => { + private cleanup() { + const { requireModuleIdSet, scopeContextLRU, scopeModuleLRU } = + this.cleanableScope + Array.from(requireModuleIdSet.values()).forEach((id) => { delete this.require.cache[id] }) - this.requireModuleIdSet.clear() + requireModuleIdSet.clear() + scopeContextLRU.clear() + scopeModuleLRU.clear() } private resolvePath(id: string) { @@ -446,8 +385,13 @@ export class ServerlessService implements OnModuleInit { ? createRequire(resolve(process.cwd(), './node_modules')) : createRequire(NODE_REQUIRE_PATH) - private inNewContextRequire() { + private createNewContextRequire(scope: string) { const __require = (id: string) => { + const cacheKey = `${scope}_${id}` + if (this.cleanableScope.scopeModuleLRU.has(cacheKey)) { + return this.cleanableScope.scopeModuleLRU.get(cacheKey) + } + const isBuiltin = isBuiltinModule(id) const resolvePath = this.resolvePath(id) @@ -456,9 +400,11 @@ export class ServerlessService implements OnModuleInit { // eslint-disable-next-line no-empty if (Object.keys(PKG.dependencies).includes(id) || isBuiltin) { } else { - this.requireModuleIdSet.add(resolvePath) + this.cleanableScope.requireModuleIdSet.add(resolvePath) } const clonedModule = deepCloneWithFunction(module) + this.cleanableScope.scopeModuleLRU.set(cacheKey, clonedModule) + return clonedModule } @@ -577,6 +523,107 @@ export class ServerlessService implements OnModuleInit { return $require.bind(this) } + private async createScopeContext( + scope: string, + context: any, + model: SnippetModel, + logger: Logger, + ) { + if (this.cleanableScope.scopeContextLRU.has(scope)) { + return this.cleanableScope.scopeContextLRU.get(scope) + } + + const document = await this.model.findById(model.id) + const secretObj = model.secret + ? qs.parse(EncryptUtil.decrypt(model.secret)) + : {} + + if (!isPlainObject(secretObj)) { + throw new InternalServerErrorException( + `secret parsing error, must be object, got ${typeof secretObj}`, + ) + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this + const createdContext = { + context: { + // inject app req, res + ...context, + ...context.res, + query: context.req.query, + headers: context.req.headers, + // TODO wildcard params + params: Object.assign({}, context.req.params), + + storage: { + cache: this.mockStorageCache(), + db: this.mockDb( + `${model.reference || '#########debug######'}@${model.name}`, + ), + dangerousAccessDbInstance: () => { + return [this.databaseService.db, mongo] + }, + }, + + secret: secretObj, + + model, + document, + name: model.name, + reference: model.reference, + getMaster: this.mockGetMaster.bind(this), + getService: this.getService.bind(this), + + writeAsset: async ( + path: string, + data: any, + options: Parameters[2], + ) => { + return await this.assetService.writeUserCustomAsset( + safePathJoin(path), + data, + options, + ) + }, + readAsset: async ( + path: string, + options: Parameters[1], + ) => { + return await this.assetService.getAsset(safePathJoin(path), options) + }, + }, + + // inject global + __dirname: DATA_DIR, + __filename: '', + + // inject some zx utils + fetch, + + // inject Global API + Buffer, + + // inject logger + console: logger, + logger, + + require: this.createNewContextRequire(scope), + import(module: string) { + return Promise.resolve(self.require(module)) + }, + + process: { + env: Object.freeze({ ...process.env }), + nextTick: process.nextTick.bind(null), + }, + } + + this.cleanableScope.scopeContextLRU.set(scope, createdContext) + + return createdContext + } + async isValidServerlessFunction(raw: string) { try { // 验证 handler 是否存在并且是函数 @@ -673,7 +720,6 @@ export class ServerlessService implements OnModuleInit { if (!builtIn) { throw new InternalServerErrorException('built-in function not found') } - console.log('reset built-in function: ', name, builtIn.code) await this.model.updateOne( {